# 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 // ... } } ```