mirror of
				https://github.com/kata-containers/kata-containers.git
				synced 2025-10-31 01:13:02 +00:00 
			
		
		
		
	Specify language for code block in docs/Unit-Test-Advice.md for syntax highlight. Fixes: #5064 Signed-off-by: Bin Liu <bin@hyper.sh>
		
			
				
	
	
		
			378 lines
		
	
	
		
			9.7 KiB
		
	
	
	
		
			Markdown
		
	
	
	
	
	
			
		
		
	
	
			378 lines
		
	
	
		
			9.7 KiB
		
	
	
	
		
			Markdown
		
	
	
	
	
	
| # Unit Test Advice
 | |
| 
 | |
| ## Overview
 | |
| 
 | |
| This document offers advice on writing a Unit Test (UT) in
 | |
| [Golang](https://golang.org) and [Rust](https://www.rust-lang.org).
 | |
| 
 | |
| ## General advice
 | |
| 
 | |
| ### Unit test strategies
 | |
| 
 | |
| #### Positive and negative tests
 | |
| 
 | |
| Always add positive tests (where success is expected) *and* negative
 | |
| tests (where failure is expected).
 | |
| 
 | |
| #### Boundary condition tests
 | |
| 
 | |
| Try to add unit tests that exercise boundary conditions such as:
 | |
| 
 | |
| - Missing values (`null` or `None`).
 | |
| - Empty strings and huge strings.
 | |
| - Empty (or uninitialised) complex data structures
 | |
|   (such as lists, vectors and hash tables).
 | |
| - Common numeric values (such as `-1`, `0`, `1` and the minimum and
 | |
|   maximum values).
 | |
| 
 | |
| #### Test unusual values
 | |
| 
 | |
| Also always consider "unusual" input values such as:
 | |
| 
 | |
| - String values containing spaces, Unicode characters, special
 | |
|   characters, escaped characters or null bytes.
 | |
| 
 | |
|   > **Note:** Consider these unusual values in prefix, infix and
 | |
|   > suffix position.
 | |
| 
 | |
| - String values that cannot be converted into numeric values or which
 | |
|   contain invalid structured data (such as invalid JSON).
 | |
| 
 | |
| #### Other types of tests
 | |
| 
 | |
| If the code requires other forms of testing (such as stress testing,
 | |
| fuzz testing and integration testing), raise a GitHub issue and
 | |
| reference it on the issue you are using for the main work. This
 | |
| ensures the test team are aware that a new test is required.
 | |
| 
 | |
| ### Test environment
 | |
| 
 | |
| #### Create unique files and directories
 | |
| 
 | |
| Ensure your tests do not write to a fixed file or directory. This can
 | |
| cause problems when running multiple tests simultaneously and also
 | |
| when running tests after a previous test run failure.
 | |
| 
 | |
| #### Assume parallel testing
 | |
| 
 | |
| Always assume your tests will be run *in parallel*. If this is
 | |
| problematic for a test, force it to run in isolation using the
 | |
| `serial_test` crate for Rust code for example.
 | |
| 
 | |
| ### Running
 | |
| 
 | |
| Ensure you run the unit tests and they all pass before raising a PR.
 | |
| Ideally do this on different distributions on different architectures
 | |
| to maximise coverage (and so minimise surprises when your code runs in
 | |
| the CI).
 | |
| 
 | |
| ## Assertions
 | |
| 
 | |
| ### Golang assertions
 | |
| 
 | |
| Use the `testify` assertions package to create a new assertion object as this
 | |
| keeps the test code free from distracting `if` tests:
 | |
| 
 | |
| ```go
 | |
| func TestSomething(t *testing.T) {
 | |
|     assert := assert.New(t)
 | |
| 
 | |
|     err := doSomething()
 | |
|     assert.NoError(err)
 | |
| }
 | |
| ```
 | |
| 
 | |
| ### Rust assertions
 | |
| 
 | |
| Use the standard set of `assert!()` macros.
 | |
| 
 | |
| ## Table driven tests
 | |
| 
 | |
| Try to write tests using a table-based approach. This allows you to distill
 | |
| the logic into a compact table (rather than spreading the tests across
 | |
| multiple test functions). It also makes it easy to cover all the
 | |
| interesting boundary conditions:
 | |
| 
 | |
| ### Golang table driven tests
 | |
| 
 | |
| Assume the following function:
 | |
| 
 | |
| ```go
 | |
| // The function under test.
 | |
| //
 | |
| // Accepts a string and an integer and returns the
 | |
| // result of sticking them together separated by a dash as a string.
 | |
| func joinParamsWithDash(str string, num int) (string, error) {
 | |
|     if str == "" {
 | |
|         return "", errors.New("string cannot be blank")
 | |
|     }
 | |
| 
 | |
|     if num <= 0 {
 | |
|         return "", errors.New("number must be positive")
 | |
|     }
 | |
| 
 | |
|     return fmt.Sprintf("%s-%d", str, num), nil
 | |
| }
 | |
| ```
 | |
| 
 | |
| A table driven approach to testing it:
 | |
| 
 | |
| ```go
 | |
| import (
 | |
|     "testing"
 | |
|     "github.com/stretchr/testify/assert"
 | |
| )
 | |
| 
 | |
| func TestJoinParamsWithDash(t *testing.T) {
 | |
|     assert := assert.New(t)
 | |
| 
 | |
|     // Type used to hold function parameters and expected results.
 | |
|     type testData struct {
 | |
|         param1         string
 | |
|         param2         int
 | |
|         expectedResult string
 | |
|         expectError    bool
 | |
|     }
 | |
| 
 | |
|     // List of tests to run including the expected results
 | |
|     data := []testData{
 | |
|         // Failure scenarios
 | |
|         {"", -1, "", true},
 | |
|         {"", 0, "", true},
 | |
|         {"", 1, "", true},
 | |
|         {"foo", 0, "", true},
 | |
|         {"foo", -1, "", true},
 | |
| 
 | |
|         // Success scenarios
 | |
|         {"foo", 1, "foo-1", false},
 | |
|         {"bar", 42, "bar-42", false},
 | |
|     }
 | |
| 
 | |
|     // Run the tests
 | |
|     for i, d := range data {
 | |
|         // Create a test-specific string that is added to each assert
 | |
|         // call. It will be displayed if any assert test fails.
 | |
|         msg := fmt.Sprintf("test[%d]: %+v", i, d)
 | |
| 
 | |
|         // Call the function under test
 | |
|         result, err := joinParamsWithDash(d.param1, d.param2)
 | |
| 
 | |
|         // update the message for more information on failure
 | |
|         msg = fmt.Sprintf("%s, result: %q, err: %v", msg, result, err)
 | |
| 
 | |
|         if d.expectError {
 | |
|             assert.Error(err, msg)
 | |
| 
 | |
|             // If an error is expected, there is no point
 | |
|             // performing additional checks.
 | |
|             continue
 | |
|         }
 | |
| 
 | |
|         assert.NoError(err, msg)
 | |
|         assert.Equal(d.expectedResult, result, msg)
 | |
|     }
 | |
| }
 | |
| ```
 | |
| 
 | |
| ### Rust table driven tests
 | |
| 
 | |
| Assume the following function:
 | |
| 
 | |
| ```rust
 | |
| // Convenience type to allow Result return types to only specify the type
 | |
| // for the true case; failures are specified as static strings.
 | |
| // XXX: This is an example. In real code use the "anyhow" and
 | |
| // XXX: "thiserror" crates.
 | |
| pub type Result<T> = std::result::Result<T, &'static str>;
 | |
| 
 | |
| // The function under test.
 | |
| //
 | |
| // Accepts a string and an integer and returns the
 | |
| // result of sticking them together separated by a dash as a string.
 | |
| fn join_params_with_dash(str: &str, num: i32) -> Result<String> {
 | |
|     if str.is_empty() {
 | |
|         return Err("string cannot be blank");
 | |
|     }
 | |
| 
 | |
|     if num <= 0 {
 | |
|         return Err("number must be positive");
 | |
|     }
 | |
| 
 | |
|     let result = format!("{}-{}", str, num);
 | |
| 
 | |
|     Ok(result)
 | |
| }
 | |
| 
 | |
| ```
 | |
| 
 | |
| A table driven approach to testing it:
 | |
| 
 | |
| ```rust
 | |
| #[cfg(test)]
 | |
| mod tests {
 | |
|     use super::*;
 | |
| 
 | |
|     #[test]
 | |
|     fn test_join_params_with_dash() {
 | |
|         // This is a type used to record all details of the inputs
 | |
|         // and outputs of the function under test.
 | |
|         #[derive(Debug)]
 | |
|         struct TestData<'a> {
 | |
|             str: &'a str,
 | |
|             num: i32,
 | |
|             result: Result<String>,
 | |
|         }
 | |
| 
 | |
|         // The tests can now be specified as a set of inputs and outputs
 | |
|         let tests = &[
 | |
|             // Failure scenarios
 | |
|             TestData {
 | |
|                 str: "",
 | |
|                 num: 0,
 | |
|                 result: Err("string cannot be blank"),
 | |
|             },
 | |
|             TestData {
 | |
|                 str: "foo",
 | |
|                 num: -1,
 | |
|                 result: Err("number must be positive"),
 | |
|             },
 | |
| 
 | |
|             // Success scenarios
 | |
|             TestData {
 | |
|                 str: "foo",
 | |
|                 num: 42,
 | |
|                 result: Ok("foo-42".to_string()),
 | |
|             },
 | |
|             TestData {
 | |
|                 str: "-",
 | |
|                 num: 1,
 | |
|                 result: Ok("--1".to_string()),
 | |
|             },
 | |
|         ];
 | |
| 
 | |
|         // Run the tests
 | |
|         for (i, d) in tests.iter().enumerate() {
 | |
|             // Create a string containing details of the test
 | |
|             let msg = format!("test[{}]: {:?}", i, d);
 | |
| 
 | |
|             // Call the function under test
 | |
|             let result = join_params_with_dash(d.str, d.num);
 | |
| 
 | |
|             // Update the test details string with the results of the call
 | |
|             let msg = format!("{}, result: {:?}", msg, result);
 | |
| 
 | |
|             // Perform the checks
 | |
|             if d.result.is_ok() {
 | |
|                 assert!(result == d.result, msg);
 | |
|                 continue;
 | |
|             }
 | |
| 
 | |
|             let expected_error = format!("{}", d.result.as_ref().unwrap_err());
 | |
|             let actual_error = format!("{}", result.unwrap_err());
 | |
|             assert!(actual_error == expected_error, msg);
 | |
|         }
 | |
|     }
 | |
| }
 | |
| ```
 | |
| 
 | |
| ## Temporary files
 | |
| 
 | |
| Use `t.TempDir()` to create temporary directory. The directory created by
 | |
| `t.TempDir()` is automatically removed when the test and all its subtests
 | |
| complete.
 | |
| 
 | |
| ### Golang temporary files
 | |
| 
 | |
| ```go
 | |
| func TestSomething(t *testing.T) {
 | |
|     assert := assert.New(t)
 | |
| 
 | |
|     // Create a temporary directory
 | |
|     tmpdir := t.TempDir()
 | |
| 
 | |
|     // Add test logic that will use the tmpdir here...
 | |
| }
 | |
| ```
 | |
| 
 | |
| ### Rust temporary files
 | |
| 
 | |
| Use the `tempfile` crate which allows files and directories to be deleted
 | |
| automatically:
 | |
| 
 | |
| ```rust
 | |
| #[cfg(test)]
 | |
| mod tests {
 | |
|     use tempfile::tempdir;
 | |
| 
 | |
|     #[test]
 | |
|     fn test_something() {
 | |
| 
 | |
|         // Create a temporary directory (which will be deleted automatically
 | |
|         let dir = tempdir().expect("failed to create tmpdir");
 | |
| 
 | |
|         let filename = dir.path().join("file.txt");
 | |
| 
 | |
|         // create filename ...
 | |
|     }
 | |
| }
 | |
| 
 | |
| ```
 | |
| 
 | |
| ## Test user
 | |
| 
 | |
| [Unit tests are run *twice*](../src/runtime/go-test.sh):
 | |
| 
 | |
| - as the current user
 | |
| - as the `root` user (if different to the current user)
 | |
| 
 | |
| When writing a test consider which user should run it; even if the code the
 | |
| test is exercising runs as `root`, it may be necessary to *only* run the test
 | |
| as a non-`root` for the test to be meaningful. Add appropriate skip
 | |
| guards around code that requires `root` and non-`root` so that the test
 | |
| will run if the correct type of user is detected and skipped if not.
 | |
| 
 | |
| ### Run Golang tests as a different user
 | |
| 
 | |
| The main repository has the most comprehensive set of skip abilities. See:
 | |
| 
 | |
| - [`katatestutils`](../src/runtime/pkg/katatestutils)
 | |
| 
 | |
| ### Run Rust tests as a different user
 | |
| 
 | |
| One method is to use the `nix` crate along with some custom macros:
 | |
| 
 | |
| ```rust
 | |
| #[cfg(test)]
 | |
| mod tests {
 | |
|     #[allow(unused_macros)]
 | |
|     macro_rules! skip_if_root {
 | |
|         () => {
 | |
|             if nix::unistd::Uid::effective().is_root() {
 | |
|                 println!("INFO: skipping {} which needs non-root", module_path!());
 | |
|                 return;
 | |
|             }
 | |
|         };
 | |
|     }
 | |
| 
 | |
|     #[allow(unused_macros)]
 | |
|     macro_rules! skip_if_not_root {
 | |
|         () => {
 | |
|             if !nix::unistd::Uid::effective().is_root() {
 | |
|                 println!("INFO: skipping {} which needs root", module_path!());
 | |
|                 return;
 | |
|             }
 | |
|         };
 | |
|     }
 | |
| 
 | |
|     #[test]
 | |
|     fn test_that_must_be_run_as_root() {
 | |
|         // Not running as the superuser, so skip.
 | |
|         skip_if_not_root!();
 | |
| 
 | |
|         // Run test *iff* the user running the test is root
 | |
| 
 | |
|         // ...
 | |
|     }
 | |
| }
 | |
| ```
 |