Skip to content
Learn Rust
Theme

Testing

Testing is built into Rust and Cargo, and needs no extra library: cargo test compiles your code in test mode and runs the test program it produces (the Book). Unit tests live next to your code, integration tests live in a tests/ folder, doc tests live in your comments, and cargo test runs them all.

RememberMark a function with #[test] and check with assert_eq!. cargo test does the rest.

Project layout
temperature/
├── src/
│   └── lib.rs                  the code, plus unit tests at the bottom
├── tests/
│   └── round_trip.rs           an integration test: uses the crate from outside
├── Cargo.lock                  created by Cargo
└── Cargo.toml                  dependencies go here
Rust
/// Convert Celsius to Fahrenheit.
///
/// A code block in a doc comment is a doc test: `cargo test` compiles and runs it.
///
/// ```
/// assert_eq!(temperature::to_fahrenheit(0.0), 32.0);
/// ```
pub fn to_fahrenheit(celsius: f64) -> f64 {
celsius * 9.0 / 5.0 + 32.0
}
/// Parse text like "21C" or "70F" into Celsius.
///
/// ```
/// use temperature::parse_celsius;
///
/// assert_eq!(parse_celsius("70F"), Ok(21.11111111111111));
/// assert!(parse_celsius("hot").is_err());
/// ```
pub fn parse_celsius(text: &str) -> Result<f64, String> {
// The unit is the last character, not the last byte: "21°" must be an error,
// not a panic from slicing through the middle of a two-byte character.
let Some(unit) = text.chars().last() else {
return Err("empty input".to_string());
};
let number = &text[..text.len() - unit.len_utf8()];
let value: f64 = number
.parse()
.map_err(|_| format!("not a number: {number:?}"))?;
match unit {
'C' => Ok(value),
'F' => Ok((value - 32.0) * 5.0 / 9.0),
_ => Err(format!("unknown unit: {unit:?}")),
}
}
// Unit tests live next to the code. #[cfg(test)] leaves them out of normal builds.
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn boiling_point() {
assert_eq!(to_fahrenheit(100.0), 212.0);
}
#[test]
fn parses_fahrenheit() {
let parsed = parse_celsius("212F");
println!("212F parsed to {parsed:?}");
assert_eq!(parsed, Ok(100.0));
}
#[test]
fn rejects_unknown_unit() {
assert!(parse_celsius("20K").is_err());
assert!(parse_celsius("21°").is_err());
}
}
$ cargo test -- --test-threads=1
   Compiling temperature v0.1.0 (/home/you/temperature)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.11s
     Running unittests src/lib.rs (target/debug/deps/temperature-dbd6a6154de66132)

running 3 tests
test tests::boiling_point ... ok
test tests::parses_fahrenheit ... ok
test tests::rejects_unknown_unit ... ok

test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

     Running tests/round_trip.rs (target/debug/deps/round_trip-52cb981126fb5eb5)

running 1 test
test round_trip ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests temperature

running 2 tests
test src/lib.rs - parse_celsius (line 14) ... ok
test src/lib.rs - to_fahrenheit (line 5) ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

all doctests ran in 0.14s; merged doctests compilation took 0.14s

Cargo ran three kinds of test, one block each: the unit tests in src/lib.rs, the integration test in tests/round_trip.rs, and the two doc tests from the /// comments.

Three things in test output change, on this page and on yours. The hex after each file name (temperature-dbd6a6154de66132) was the same in every run I made here, but it may differ between machines and toolchains. The times (finished in 0.00s) and the thread number in a panic message differ from run to run. Also, the tests of one test program run in parallel threads by default (the Book), so their lines can come out in a different order each time. I measured it: 25 runs of plain cargo test on this project gave 5 distinct outputs (ignoring the times), and 25 runs with -- --test-threads=1 gave 1. That is one machine, not a guarantee. Everything after -- goes to the test program, and -- --test-threads=1 runs one test at a time. Every cargo test on this page uses it where there are several test lines, so the order is the same every run.

Go
package temperature
import (
"fmt"
"strconv"
)
// ToFahrenheit converts Celsius to Fahrenheit.
func ToFahrenheit(c float64) float64 { return c*9/5 + 32 }
// ParseCelsius parses text like "21C" or "70F" into Celsius.
func ParseCelsius(text string) (float64, error) {
if len(text) < 2 {
return 0, fmt.Errorf("too short: %q", text)
}
num, unit := text[:len(text)-1], text[len(text)-1:]
v, err := strconv.ParseFloat(num, 64)
if err != nil {
return 0, fmt.Errorf("not a number: %q", num)
}
switch unit {
case "C":
return v, nil
case "F":
return (v - 32) * 5 / 9, nil
}
return 0, fmt.Errorf("unknown unit: %q", unit)
}
$ go test -v
=== RUN   TestBoilingPoint
--- PASS: TestBoilingPoint (0.00s)
=== RUN   TestParsesFahrenheit
--- PASS: TestParsesFahrenheit (0.00s)
=== RUN   TestRejectsUnknownUnit
--- PASS: TestRejectsUnknownUnit (0.00s)
PASS
ok  	example.com/temperature	0.001s

Go prints timings too (0.00s, 0.001s), and they vary from run to run.

  • Unit tests sit in a mod tests inside the same file, so use super::* gives them the module’s private functions too (the Book). #[cfg(test)] means that module is compiled by cargo test, not by cargo build.
  • Integration tests in tests/ use your crate like any outside user would, through its public API. Each file there is its own crate, so it needs its own use temperature::.... They need a library (src/lib.rs): a crate that only has src/main.rs cannot be imported (the Book).
  • Doc tests: the code blocks in the /// comments of your library are compiled and run by cargo test (Cargo Book), so an example that stops working shows up as a failing test. Each block behaves like its own small program that uses your crate from outside, which is why the second example starts with use temperature::parse_celsius;.
  • Extras: proptest for property-based testing, insta for snapshot tests, mockall for mocks. Crates used only by tests go under [dev-dependencies] in Cargo.toml: they are compiled for tests, examples and benchmarks, not for a normal build (Cargo Book). This page does not run those three crates.

A separate small crate, kelvin, shows the other attributes. This is a different project from the one above, so it has its own test run:

Rustsrc/lib.rs
/// Kelvin from Celsius. Panics below absolute zero.
pub fn to_kelvin(celsius: f64) -> f64 {
assert!(celsius >= -273.15, "below absolute zero: {celsius}");
celsius + 273.15
}
#[cfg(test)]
mod tests {
use super::*;
// Passes only if the code panics with a message containing this text.
#[test]
#[should_panic(expected = "below absolute zero")]
fn rejects_below_absolute_zero() {
to_kelvin(-300.0);
}
// A test may return a Result, so `?` works inside it. Err fails the test.
#[test]
fn freezing_point() -> Result<(), String> {
let text = "0";
let celsius: f64 = text.parse().map_err(|e| format!("{e}"))?;
assert_eq!(to_kelvin(celsius), 273.15);
Ok(())
}
// Skipped by a plain `cargo test`; `cargo test -- --ignored` runs only these.
#[test]
#[ignore = "pretend this one is slow"]
fn absolute_zero() {
assert_eq!(to_kelvin(-273.15), 0.0);
}
// Floating point: compare with a tolerance unless you know the result is exact.
#[test]
fn floats_need_a_tolerance() {
assert_ne!(0.1 + 0.2, 0.3);
assert!((0.1_f64 + 0.2 - 0.3).abs() < 1e-9);
}
}
$ cargo test -- --test-threads=1
running 4 tests
test tests::absolute_zero ... ignored, pretend this one is slow
test tests::floats_need_a_tolerance ... ok
test tests::freezing_point ... ok
test tests::rejects_below_absolute_zero - should panic ... ok

test result: ok. 3 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests kelvin

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
$ cargo test -- --ignored
running 1 test
test tests::absolute_zero ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 3 filtered out; finished in 0.00s

   Doc-tests kelvin

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
  • #[should_panic(expected = "...")] passes only if the code panics, and the panic message contains that text.
  • A test can return Result<(), E>, so ? works inside it, and an Err fails the test. You cannot combine this with #[should_panic] (the Book).
  • #[ignore] leaves a test out of a normal run, and cargo test -- --ignored runs only those, as the second command shows.
  • Floating-point sums are not always exact: the last test asserts that 0.1 + 0.2 is not 0.3 here and then compares with a tolerance. The unit and integration tests above compare results that are exact (212.0, 100.0, 68.0). The doc test compares 190/9 with the literal 21.11111111111111: that passes here because the literal rounds to the same f64, but it is fragile, and a tolerance is safer for results like that.
  1. Run a subset. cargo test boiling runs the tests whose name contains boiling, and says how many it skipped (2 filtered out). --lib runs only the unit tests and --doc only the doc tests (cargo test). The test names are matched as substrings (libtest options):

    terminal
    $ cargo test boiling
       Compiling temperature v0.1.0 (/home/you/temperature)
        Finished `test` profile [unoptimized + debuginfo] target(s) in 0.09s
         Running unittests src/lib.rs (target/debug/deps/temperature-dbd6a6154de66132)
    
    running 1 test
    test tests::boiling_point ... ok
    
    test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 2 filtered out; finished in 0.00s
    
         Running tests/round_trip.rs (target/debug/deps/round_trip-52cb981126fb5eb5)
    
    running 0 tests
    
    test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 1 filtered out; finished in 0.00s
    $ cargo test --doc
       Doc-tests temperature
    
    running 2 tests
    test src/lib.rs - parse_celsius (line 14) ... ok
    test src/lib.rs - to_fahrenheit (line 5) ... ok
    
    test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
    
    all doctests ran in 0.12s; merged doctests compilation took 0.12s
    
  2. See what a test prints. The parses_fahrenheit test above has a println!("212F parsed to {parsed:?}");. A passing test’s output is captured and not shown. --no-capture lets it print as it happens; --show-output prints the output of passing tests after all the tests have run (in a successes: block). The rustc book lists --nocapture as a deprecated alias of --no-capture; the last command below ran it in this image and it printed the same line, with no warning:

    terminal
    $ cargo test --lib parses
    running 1 test
    test tests::parses_fahrenheit ... ok
    
    test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 2 filtered out; finished in 0.00s
    $ cargo test --lib parses -- --no-capture
    running 1 test
    212F parsed to Ok(100.0)
    test tests::parses_fahrenheit ... ok
    
    test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 2 filtered out; finished in 0.00s
    $ cargo test --lib parses -- --show-output
    running 1 test
    test tests::parses_fahrenheit ... ok
    
    successes:
    
    ---- tests::parses_fahrenheit stdout ----
    212F parsed to Ok(100.0)
    
    successes:
        tests::parses_fahrenheit
    
    test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 2 filtered out; finished in 0.00s
    $ cargo test --lib parses -- --nocapture
    running 1 test
    212F parsed to Ok(100.0)
    test tests::parses_fahrenheit ... ok
    
    test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 2 filtered out; finished in 0.00s
    
  3. Break it on purpose. Change + 32.0 to + 23.0 in to_fahrenheit, and run cargo test -- --test-threads=1. The failing test prints the panic location and both values: left is the first argument of assert_eq! (what the code returned) and right the second. Cargo then stops: the integration and doc tests did not run, because without --no-fail-fast Cargo exits after the first test program that fails (cargo test). The number in parentheses after the thread name differs every run:

    $ cargo test -- --test-threads=1
       Compiling temperature v0.1.0 (/home/you/temperature)
        Finished `test` profile [unoptimized + debuginfo] target(s) in 0.10s
         Running unittests src/lib.rs (target/debug/deps/temperature-dbd6a6154de66132)
    
    running 3 tests
    test tests::boiling_point ... FAILED
    test tests::parses_fahrenheit ... ok
    test tests::rejects_unknown_unit ... ok
    
    failures:
    
    ---- tests::boiling_point stdout ----
    
    thread 'tests::boiling_point' (525) panicked at src/lib.rs:44:9:
    assertion `left == right` failed
      left: 203.0
     right: 212.0
    note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
    
    
    failures:
        tests::boiling_point
    
    test result: FAILED. 2 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
    
    error: test failed, to rerun pass `--lib`
Language Choices (not a ranking) Part of the language’s own tools? Ran here
Rust the built-in harness, #[test] yes: cargo test yes (above)
Go the testing package, _test.go files yes: go test (docs) yes (above)
Python unittest; pytest is a third-party package unittest is in the standard library (docs) unittest yes, pytest no
TypeScript node:test; vitest, jest node:test ships with Node and is marked stable (docs) node --test yes (Node 24 ran the .ts file directly, without type-checking it), the others no
Java JUnit no: a separate framework, and the JDK in the image has no JUnit no
Kotlin the kotlin.test library on JUnit (docs), or Kotest no: you add them in the build file, as the docs’ Gradle example does no
C# xUnit, NUnit, MSTest dotnet test is a .NET SDK command that runs tests written with a framework such as xUnit.net or MSTest (docs) no
C Unity, CMocka no test framework: you build a test program and run it. assert.h gives only an assert macro that aborts (man page) no
C++ GoogleTest, Catch2 no: same no

For the Java and Kotlin build tools, Gradle’s docs describe a test task that runs the unit tests (docs); I did not run Gradle, Maven, dotnet test or any of the C and C++ frameworks. The image has no test framework for Java, Kotlin, C#, C or C++, so those are not run; Rust, Go, Python (unittest) and Node (node --test) are real runs. Test runners print timings, so the numbers below vary:

The Python and Node runs
Python and TypeScript
import unittest
def to_fahrenheit(celsius):
return celsius * 9 / 5 + 32
class TemperatureTests(unittest.TestCase):
def test_boiling_point(self):
self.assertEqual(to_fahrenheit(100), 212)
def test_freezing_point(self):
self.assertEqual(to_fahrenheit(0), 32)
if __name__ == "__main__":
unittest.main()
$ python3 -m unittest -v
test_boiling_point (test_temperature.TemperatureTests.test_boiling_point) ... ok
test_freezing_point (test_temperature.TemperatureTests.test_freezing_point) ... ok

----------------------------------------------------------------------
Ran 2 tests in 0.000s

OK
$ node --test temperature.test.ts
✔ boiling point (0.626742ms)
✔ freezing point (0.056083ms)
ℹ tests 2
ℹ suites 0
ℹ pass 2
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 43.47771