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 ```