Skip to main contentTesting | IoT Worker

Testing

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 function
  • assert_eq! compares actual and expected values
  • #[cfg(test)] compiles this module only for test builds
  • use 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.

Continue Reading