C project tests often start as another main:
#include <assert.h>
int main(void)
{
assert(checksum((uint8_t[]){1, 2, 3}, 3) == 6);
return 0;
}
Or as small programs, shell scripts, and Makefile targets:
test:
./test_checksum
./test_parser
Rust puts the entry point into Cargo:
cargo test
First model: Rust tests are not a separate project system. They are part of the crate.
Minimal Unit Test
A normal function:
fn checksum(data: &[u8]) -> u32 {
data.iter().map(|b| *b as u32).sum()
}
A nearby test:
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn checksum_sums_bytes() {
assert_eq!(checksum(&[1, 2, 3]), 6);
}
}
Run it:
cargo test
The pieces:
#[test]marks a test functionassert_eq!compares actual and expected values#[cfg(test)]compiles this module only for test buildsuse super::*lets the test access items in the current module
This brings the C test main close to the code it tests.
Tests Can Access Private Functions
Testing static functions in C can be awkward: expose them through a header, or include the .c file into a test program.
Rust module tests can access private items in the same module:
fn parse_len(data: &[u8]) -> Option<u16> {
if data.len() < 2 {
return None;
}
Some(u16::from_be_bytes([data[0], data[1]]))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_len_reads_big_endian() {
assert_eq!(parse_len(&[0x01, 0x02]), Some(258));
}
}
You do not need to turn every internal helper into public API just to test it.
Assertions and Failure Messages
Common assertions:
assert!(value > 0);
assert_eq!(actual, expected);
assert_ne!(left, right);
Add a failure message:
assert_eq!(actual, expected, "bad packet length for {case_name}");
A test can return Result:
#[test]
fn load_config() -> Result<(), Box<dyn std::error::Error>> {
let text = std::fs::read_to_string("testdata/config.toml")?;
assert!(text.contains("port"));
Ok(())
}
That lets the test use ?. Failures are reported by the test framework.
Test Error Paths
C tests often cover only successful paths. Rust Result and Option make error branches easy to test:
#[derive(Debug, PartialEq, Eq)]
enum ParseError {
TooShort,
BadVersion(u8),
}
fn parse_packet(data: &[u8]) -> Result<(), ParseError> {
if data.len() < 2 {
return Err(ParseError::TooShort);
}
if data[0] != 1 {
return Err(ParseError::BadVersion(data[0]));
}
Ok(())
}
#[test]
fn parse_rejects_short_packet() {
assert_eq!(parse_packet(&[1]), Err(ParseError::TooShort));
}
For direct comparison, derive PartialEq and Eq on error types when it makes sense. If an error contains values that are hard to compare, such as io::Error, compare the stable classification instead.
Integration Tests Live in tests/
Module tests are good for internal logic. Public APIs can be tested from tests/:
mycrate/
├── src/
│ └── lib.rs
└── tests/
└── parser_test.rs
tests/parser_test.rs uses the crate like an outside user:
use mycrate::parse_packet;
#[test]
fn parses_valid_packet() {
let packet = [1_u8, 0];
assert!(parse_packet(&packet).is_ok());
}
Integration tests can only access public API. This is close to black-box testing through C headers.
If the project started with only src/main.rs, move core logic into src/lib.rs so main.rs only handles the CLI boundary.
Testing CLI Tools
The simplest approach is to test business logic directly, not the process:
pub fn line_count(text: &str) -> usize {
text.lines().count()
}
#[test]
fn counts_lines() {
assert_eq!(line_count("a\nb\n"), 2);
}
stdout, stderr, and exit status can be tested later with integration tests or helper tools. First, avoid putting all logic into main.
This matches a familiar C project structure: put core logic in a library, leave argument parsing and output in main.
Keep Fixed Inputs in testdata
Parsers, protocols, and file formats often need fixed samples:
tests/
├── parser_test.rs
└── testdata/
├── valid.bin
└── bad-version.bin
Read them in tests:
#[test]
fn parses_sample() -> Result<(), Box<dyn std::error::Error>> {
let data = std::fs::read("tests/testdata/valid.bin")?;
assert!(parse_packet(&data).is_ok());
Ok(())
}
Test data should be stable and version-controlled. Avoid tests that depend on temporary files on one developer’s machine, incidental current directory state, or network services.
Running Part of the Test Suite
Common commands:
cargo test
cargo test checksum
cargo test -- --nocapture
Meaning:
cargo test -> run all tests
cargo test checksum -> run tests whose names contain checksum
-- --nocapture -> show stdout/stderr from tests
Clear test names matter:
#[test]
fn parse_rejects_bad_version() {}
#[test]
fn parse_rejects_short_packet() {}
When a test fails, the name is the first clue.
What to Keep
The first C-to-Rust testing mapping:
handwritten test_main.c -> #[test] + cargo test
assert / assert.h -> assert! / assert_eq! / assert_ne!
testing static functions -> module-local #[cfg(test)] tests
black-box public API -> tests/ integration tests
fixed input samples -> tests/testdata/
fallible test setup -> test function returns Result
hard-to-test main -> move core logic into lib.rs / normal functions
Rust’s testing entry point exists from the beginning of the project. For C developers, the important migration is not memorizing #[test]; it is separating testable core logic from the process boundary in main.