github.com/dmaizel/tests@v0.0.0-20210728163746-cae6a2d9cee8/Unit-Test-Advice.md (about)

     1  # Unit Test Advice
     2  
     3  * [Overview](#overview)
     4  * [Assertions](#assertions)
     5      * [golang assertions](#golang-assertions)
     6      * [rust assertions](#rust-assertions)
     7  * [Table driven tests](#table-driven-tests)
     8      * [golang table driven tests](#golang-table-driven-tests)
     9      * [rust table driven tests](#rust-table-driven-tests)
    10  * [Temporary files](#temporary-files)
    11      * [golang temporary files](#golang-temporary-files)
    12      * [rust temporary files](#rust-temporary-files)
    13  * [User running the test](#user-running-the-test)
    14      * [running golang tests as different users](#running-golang-tests-as-different-users)
    15      * [running rust tests as different users](#running-rust-tests-as-different-users)
    16  
    17  ## Overview
    18  
    19  This document offers advice on writing a Unit Test (UT) in
    20  [`golang`](https://golang.org) and [`rust`](https://www.rust-lang.org).
    21  
    22  ## Assertions
    23  
    24  ### golang assertions
    25  
    26  Use the `testify` assertions package to create a new assertion object as this
    27  keeps the test code free from distracting `if` tests:
    28  
    29  ```go
    30  func TestSomething(t *testing.T) {
    31      assert := assert.New(t)
    32  
    33      err := doSomething()
    34      assert.NoError(err)
    35  }
    36  ```
    37  
    38  ### rust assertions
    39  
    40  Use the standard set of `assert!()` macros.
    41  
    42  ## Table driven tests
    43  
    44  Try to write tests using a table-based approach. This allows you to distill
    45  the logic into a compact table (rather than spreading the tests across
    46  multiple test functions). It also makes it easy to cover all the
    47  interesting boundary conditions:
    48  
    49  ### golang table driven tests
    50  
    51  Assume the following function:
    52  
    53  ```go
    54  // The function under test.
    55  //
    56  // Accepts a string and an integer and returns the
    57  // result of sticking them together separated by a dash as a string.
    58  func joinParamsWithDash(str string, num int) (string, error) {
    59      if str == "" {
    60          return "", errors.New("string cannot be blank")
    61      }
    62  
    63      if num <= 0 {
    64          return "", errors.New("number must be positive")
    65      }
    66  
    67      return fmt.Sprintf("%s-%d", str, num), nil
    68  }
    69  ```
    70  
    71  A table driven approach to testing it:
    72  
    73  ```go
    74  import (
    75      "testing"
    76      "github.com/stretchr/testify/assert"
    77  )
    78  
    79  func TestJoinParamsWithDash(t *testing.T) {
    80      assert := assert.New(t)
    81  
    82      // Type used to hold function parameters and expected results.
    83      type testData struct {
    84          param1         string
    85          param2         int
    86          expectedResult string
    87          expectError    bool
    88      }
    89  
    90      // List of tests to run including the expected results
    91      data := []testData{
    92          // Failure scenarios
    93          {"", -1, "", true},
    94          {"", 0, "", true},
    95          {"", 1, "", true},
    96          {"foo", 0, "", true},
    97          {"foo", -1, "", true},
    98  
    99          // Success scenarios
   100          {"foo", 1, "foo-1", false},
   101          {"bar", 42, "bar-42", false},
   102      }
   103  
   104      // Run the tests
   105      for i, d := range data {
   106          // Create a test-specific string that is added to each assert
   107          // call. It will be displayed if any assert test fails.
   108          msg := fmt.Sprintf("test[%d]: %+v", i, d)
   109  
   110          // Call the function under test
   111          result, err := joinParamsWithDash(d.param1, d.param2)
   112  
   113          // update the message for more information on failure
   114          msg = fmt.Sprintf("%s, result: %q, err: %v", msg, result, err)
   115  
   116          if d.expectError {
   117              assert.Error(err, msg)
   118  
   119              // If an error is expected, there is no point
   120              // performing additional checks.
   121              continue
   122          }
   123  
   124          assert.NoError(err, msg)
   125          assert.Equal(d.expectedResult, result, msg)
   126      }
   127  }
   128  ```
   129  
   130  ### rust table driven tests
   131  
   132  Assume the following function:
   133  
   134  ```rust
   135  // Convenience type to allow Result return types to only specify the type
   136  // for the true case; failures are specified as static strings.
   137  pub type Result<T> = std::result::Result<T, &'static str>;
   138  
   139  // The function under test.
   140  //
   141  // Accepts a string and an integer and returns the
   142  // result of sticking them together separated by a dash as a string.
   143  fn join_params_with_dash(str: &str, num: i32) -> Result<String> {
   144      if str == "" {
   145          return Err("string cannot be blank");
   146      }
   147  
   148      if num <= 0 {
   149          return Err("number must be positive");
   150      }
   151  
   152      let result = format!("{}-{}", str, num);
   153  
   154      Ok(result)
   155  }
   156  
   157  ```
   158  
   159  A table driven approach to testing it:
   160  
   161  ```rust
   162  #[cfg(test)]
   163  mod tests {
   164      use super::*;
   165  
   166      #[test]
   167      fn test_join_params_with_dash() {
   168          // This is a type used to record all details of the inputs
   169          // and outputs of the function under test.
   170          #[derive(Debug)]
   171          struct TestData<'a> {
   172              str: &'a str,
   173              num: i32,
   174              result: Result<String>,
   175          }
   176      
   177          // The tests can now be specified as a set of inputs and outputs
   178          let tests = &[
   179              // Failure scenarios
   180              TestData {
   181                  str: "",
   182                  num: 0,
   183                  result: Err("string cannot be blank"),
   184              },
   185              TestData {
   186                  str: "foo",
   187                  num: -1,
   188                  result: Err("number must be positive"),
   189              },
   190  
   191              // Success scenarios
   192              TestData {
   193                  str: "foo",
   194                  num: 42,
   195                  result: Ok("foo-42".to_string()),
   196              },
   197              TestData {
   198                  str: "-",
   199                  num: 1,
   200                  result: Ok("--1".to_string()),
   201              },
   202          ];
   203      
   204          // Run the tests
   205          for (i, d) in tests.iter().enumerate() {
   206              // Create a string containing details of the test
   207              let msg = format!("test[{}]: {:?}", i, d);
   208      
   209              // Call the function under test
   210              let result = join_params_with_dash(d.str, d.num);
   211      
   212              // Update the test details string with the results of the call
   213              let msg = format!("{}, result: {:?}", msg, result);
   214      
   215              // Perform the checks
   216              if d.result.is_ok() {
   217                  assert!(result == d.result, msg);
   218                  continue;
   219              }
   220      
   221              let expected_error = format!("{}", d.result.as_ref().unwrap_err());
   222              let actual_error = format!("{}", result.unwrap_err());
   223              assert!(actual_error == expected_error, msg);
   224          }
   225      }
   226  }
   227  ```
   228  
   229  ## Temporary files
   230  
   231  Always delete temporary files on success.
   232  
   233  ### golang temporary files
   234  
   235  ```go
   236  func TestSomething(t *testing.T) {
   237      assert := assert.New(t)
   238  
   239      // Create a temporary directory
   240      tmpdir, err := ioutil.TempDir("", "")    
   241      assert.NoError(err)             
   242  
   243      // Delete it at the end of the test
   244      defer os.RemoveAll(tmpdir) 
   245  
   246      // Add test logic that will use the tmpdir here...
   247  }
   248  ```
   249  
   250  ### rust temporary files
   251  
   252  Use the `tempfile` crate which allows files and directories to be deleted
   253  automatically:
   254  
   255  ```rust
   256  #[cfg(test)]
   257  mod tests {
   258      use tempfile::tempdir;
   259  
   260      #[test]
   261      fn test_something() {
   262  
   263          // Create a temporary directory (which will be deleted automatically
   264          let dir = tempdir().expect("failed to create tmpdir");
   265  
   266          let filename = dir.path().join("file.txt");
   267  
   268          // create filename ...
   269      }
   270  }
   271  
   272  ```
   273  
   274  ## User running the test
   275  
   276  [Unit tests are run *twice*](https://github.com/kata-containers/tests/blob/main/.ci/go-test.sh):
   277  
   278  - as the current user
   279  - as the `root` user (if different to the current user)
   280  
   281  When writing a test consider which user should run it; even if the code the
   282  test is exercising runs as `root`, it may be necessary to *only* run the test
   283  as a non-`root` for the test to be meaningful.
   284  
   285  Some repositories already provide utility functions to skip a test:
   286  
   287  - if running as `root`
   288  - if not running as `root`
   289  
   290  ### running golang tests as different users
   291  
   292  The runtime repository has the most comprehensive set of skip abilities. See:
   293  
   294  - https://github.com/kata-containers/kata-containers/tree/main/src/runtime/pkg/katatestutils
   295  
   296  ### running rust tests as different users
   297  
   298  One method is to use the `nix` crate along with some custom macros:
   299  
   300  ```
   301  #[cfg(test)]
   302  mod tests {
   303      #[allow(unused_macros)]
   304      macro_rules! skip_if_root {
   305          () => {
   306              if nix::unistd::Uid::effective().is_root() {
   307                  println!("INFO: skipping {} which needs non-root", module_path!());
   308                  return;
   309              }
   310          };
   311      }
   312  
   313      #[allow(unused_macros)]
   314      macro_rules! skip_if_not_root {
   315          () => {
   316              if !nix::unistd::Uid::effective().is_root() {
   317                  println!("INFO: skipping {} which needs root", module_path!());
   318                  return;
   319              }
   320          };
   321      }
   322  
   323      #[test]
   324      fn test_that_must_be_run_as_root() {
   325          // Not running as the superuser, so skip.
   326          skip_if_not_root!();
   327  
   328          // Run test *iff* the user running the test is root
   329  
   330          // ...
   331      }
   332  }
   333  ```