Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Testing Generated Code

This guide covers testing strategies for parsers built with synkit, from unit tests to fuzz testing.

Unit Testing

Token-Level Tests

Test individual token recognition:

#[test]
fn test_lex_identifier() {
    let stream = TokenStream::lex("foo_bar").unwrap();
    let tok = stream.peek_token().unwrap();

    assert!(matches!(tok.value, Token::Ident(_)));
    if let Token::Ident(s) = &tok.value {
        assert_eq!(s, "foo_bar");
    }
}

#[test]
fn test_lex_rejects_invalid() {
    // Logos returns errors for unrecognized input
    let result = TokenStream::lex("\x00\x01\x02");
    assert!(result.is_err());
}

Span Accuracy Tests

Verify spans point to correct source locations:

#[test]
fn test_span_accuracy() {
    let source = "let x = 42";
    let mut stream = TokenStream::lex(source).unwrap();

    let kw: Spanned<LetToken> = stream.parse().unwrap();
    assert_eq!(&source[kw.span.start()..kw.span.end()], "let");

    let name: Spanned<IdentToken> = stream.parse().unwrap();
    assert_eq!(&source[name.span.start()..name.span.end()], "x");
}

Parse Tests

Test AST construction:

#[test]
fn test_parse_key_value() {
    let mut stream = TokenStream::lex("name = \"Alice\"").unwrap();
    let kv: Spanned<KeyValue> = stream.parse().unwrap();

    assert!(matches!(kv.key.value, Key::Bare(_)));
    assert!(matches!(kv.value.value, Value::String(_)));
}

#[test]
fn test_parse_error_recovery() {
    let mut stream = TokenStream::lex("= value").unwrap();
    let result: Result<Spanned<KeyValue>, _> = stream.parse();

    assert!(result.is_err());
    // Verify error message is helpful
    let err = result.unwrap_err();
    assert!(err.to_string().contains("expected"));
}

Round-Trip Testing

Verify parse-then-print produces equivalent output:

#[test]
fn test_roundtrip() {
    let original = "name = \"value\"\ncount = 42";

    let mut stream = TokenStream::lex(original).unwrap();
    let doc: Document = stream.parse().unwrap();

    let mut printer = Printer::new();
    doc.write(&mut printer);
    let output = printer.into_string();

    // Re-parse and compare AST
    let mut stream2 = TokenStream::lex(&output).unwrap();
    let doc2: Document = stream2.parse().unwrap();

    assert_eq!(format!("{:?}", doc), format!("{:?}", doc2));
}

Snapshot Testing

Use insta for golden-file testing:

use insta::assert_snapshot;

#[test]
fn snapshot_complex_document() {
    let input = include_str!("fixtures/complex.toml");
    let mut stream = TokenStream::lex(input).unwrap();
    let doc: Document = stream.parse().unwrap();

    assert_snapshot!(format!("{:#?}", doc));
}

#[test]
fn snapshot_formatted_output() {
    let input = "messy   =   \"spacing\"";
    let doc: Document = parse(input).unwrap();

    let mut printer = Printer::new();
    doc.write(&mut printer);

    assert_snapshot!(printer.into_string());
}

Parameterized Tests

Use test-case for table-driven tests:

use test_case::test_case;

#[test_case("42", Value::Integer(42); "positive integer")]
#[test_case("-17", Value::Integer(-17); "negative integer")]
#[test_case("true", Value::Bool(true); "boolean true")]
#[test_case("false", Value::Bool(false); "boolean false")]
fn test_parse_value(input: &str, expected: Value) {
    let mut stream = TokenStream::lex(input).unwrap();
    let value: Spanned<Value> = stream.parse().unwrap();
    assert_eq!(value.value, expected);
}

Edge Case Testing

Test boundary conditions:

#[test]
fn test_empty_input() {
    let stream = TokenStream::lex("").unwrap();
    assert!(stream.is_empty());
}

#[test]
fn test_whitespace_only() {
    let mut stream = TokenStream::lex("   \t\n  ").unwrap();
    // peek_token skips whitespace
    assert!(stream.peek_token().is_none());
}

#[test]
fn test_max_nesting() {
    let nested = "[".repeat(200) + &"]".repeat(200);
    let result = parse_array(&nested);

    // Should fail with recursion limit error
    assert!(matches!(
        result,
        Err(MyError::RecursionLimit { .. })
    ));
}

#[test]
fn test_unicode_boundaries() {
    // Multi-byte UTF-8: emoji is 4 bytes
    let input = "key = \"hello 🦀 world\"";
    let mut stream = TokenStream::lex(input).unwrap();
    let kv: Spanned<KeyValue> = stream.parse().unwrap();

    // Spans should be valid UTF-8 boundaries
    let slice = &input[kv.span.start()..kv.span.end()];
    assert!(slice.is_char_boundary(0));
}

Fuzz Testing

Setup

Add fuzz targets to your project:

# fuzz/Cargo.toml
[package]
name = "my-parser-fuzz"
version = "0.0.0"
publish = false
edition = "2021"

[package.metadata]
cargo-fuzz = true

[[bin]]
name = "fuzz_lexer"
path = "fuzz_targets/fuzz_lexer.rs"
test = false
doc = false
bench = false

[[bin]]
name = "fuzz_parser"
path = "fuzz_targets/fuzz_parser.rs"
test = false
doc = false
bench = false

[dependencies]
libfuzzer-sys = "0.4"
my-parser = { path = ".." }

Lexer Fuzzing

// fuzz/fuzz_targets/fuzz_lexer.rs
#![no_main]
use libfuzzer_sys::fuzz_target;

fuzz_target!(|data: &[u8]| {
    if let Ok(s) = std::str::from_utf8(data) {
        // Should never panic
        let _ = my_parser::TokenStream::lex(s);
    }
});

Parser Fuzzing

// fuzz/fuzz_targets/fuzz_parser.rs
#![no_main]
use libfuzzer_sys::fuzz_target;

fuzz_target!(|data: &[u8]| {
    if let Ok(s) = std::str::from_utf8(data) {
        if let Ok(mut stream) = my_parser::TokenStream::lex(s) {
            // Parse should never panic, only return errors
            let _: Result<Document, _> = stream.parse();
        }
    }
});

Running Fuzzers

# Install cargo-fuzz (requires nightly)
cargo install cargo-fuzz

# Run lexer fuzzer
cargo +nightly fuzz run fuzz_lexer

# Run with timeout and iterations
cargo +nightly fuzz run fuzz_parser -- -max_total_time=60

# Run with corpus
cargo +nightly fuzz run fuzz_parser corpus/parser/

Integration Testing

Test complete workflows:

#[test]
fn test_parse_real_file() {
    let content = std::fs::read_to_string("fixtures/config.toml").unwrap();
    let doc = parse(&content).expect("should parse real config file");

    // Verify expected structure
    assert!(doc.get_table("server").is_some());
    assert!(doc.get_value("server.port").is_some());
}

Benchmarking

Use divan or criterion for performance testing:

use divan::Bencher;

#[divan::bench]
fn bench_lex_small(bencher: Bencher) {
    let input = include_str!("fixtures/small.toml");
    bencher.bench(|| TokenStream::lex(input).unwrap());
}

#[divan::bench(args = [100, 1000, 10000])]
fn bench_lex_lines(bencher: Bencher, lines: usize) {
    let input = "key = \"value\"\n".repeat(lines);
    bencher.bench(|| TokenStream::lex(&input).unwrap());
}

CI Configuration

Example GitHub Actions workflow:

name: Test
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/rust-toolchain@stable
      - run: cargo test --all-features

  fuzz:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/rust-toolchain@nightly
      - run: cargo install cargo-fuzz
      - run: cargo +nightly fuzz run fuzz_lexer -- -max_total_time=30
      - run: cargo +nightly fuzz run fuzz_parser -- -max_total_time=30

Test Coverage

Use cargo-llvm-cov for coverage reports:

cargo install cargo-llvm-cov
cargo llvm-cov --html
open target/llvm-cov/html/index.html

Aim for high coverage on:

  • All token variants
  • All AST node types
  • Error paths
  • Edge cases (empty, whitespace, limits)