Writing Tests for Our CLI Program

Testing CLI programs requires special techniques. We need to test file I/O, argument parsing, and search logic in isolation. Let's write comprehensive tests for our grep clone.

Organizing Code for Testing

First, let's restructure our code to be testable. Create src/lib.ox alongside src/main.ox:

// src/lib.ox
import std.fs
import std.env
import std.error.Error

public struct Config {
    public query: String,
    public filename: String,
    public ignoreCase: Bool,
}

extension Config {
    public static fn new(args: &Vec<String>): Result<Config, String> {
        if args.len() < 3 {
            return Err("not enough arguments".toString())
        }

        let query = args[1].clone()
        let filename = args[2].clone()

        let commandLineFlag = args.len() > 3 && args[3] == "--ignore-case"
        let envVariable = std.env.var("OXGREP_IGNORE_CASE").isOk()

        let ignoreCase = commandLineFlag || envVariable

        Ok(Config {
            query,
            filename,
            ignoreCase,
        })
    }
}

public fn search(config: &Config, contents: &str): Vec<String> {
    var results = Vec.new()

    let query = if config.ignoreCase {
        config.query.toLowercase()
    } else {
        config.query.clone()
    }

    for line in contents.lines() {
        let searchLine = if config.ignoreCase {
            line.toLowercase()
        } else {
            line.toString()
        }

        if searchLine.contains(&query) {
            results.push(line.toString())
        }
    }

    results
}

public fn run(config: &Config): Result<(), Box<dyn Error>> {
    let contents = std.fs.readToString(&config.filename)?
    let results = search(config, &contents)

    for line in results {
        println!("{}", line)
    }

    Ok(())
}

Now src/main.ox just handles argument parsing and calls run:

// src/main.ox
import std.env
import std.error.Error
import oxgrep.*

fn main(): Result<(), Box<dyn Error>> {
    let args = std.env.args().collect<Vec<String>>()

    let config = Config.new(&args)
        .unwrapOrElse { err ->
            eprintln!("Problem parsing arguments: {}", err)
            std.process.exit(1)
        }?

    run(&config)
}

Testing the Search Function

Now we can test the core search logic:

// In src/lib.ox
#[cfg(test)]
module tests {
    import super.*

    #[test]
    fn caseInsensitive() {
        let query = "duct".toString()
        let contents = "Rust:\nsafe, fast, productive.\nPick three.".toString()

        var config = Config {
            query,
            filename: "test.txt".toString(),
            ignoreCase: true,
        }

        let results = search(&config, &contents)

        assertEq!(results.len(), 1)
        assertEq!(results[0], "safe, fast, productive.".toString())
    }

    #[test]
    fn caseSensitive() {
        let query = "duct".toString()
        let contents = "Rust:\nsafe, fast, productive.\nPick three.".toString()

        var config = Config {
            query,
            filename: "test.txt".toString(),
            ignoreCase: false,
        }

        let results = search(&config, &contents)

        assertEq!(results.len(), 1)
        assertEq!(results[0], "safe, fast, productive.".toString())
    }

    #[test]
    fn noMatches() {
        let query = "xyz".toString()
        let contents = "Rust:\nsafe, fast, productive.\nPick three.".toString()

        var config = Config {
            query,
            filename: "test.txt".toString(),
            ignoreCase: false,
        }

        let results = search(&config, &contents)

        assertEq!(results.len(), 0)
    }

    #[test]
    fn multipleMatches() {
        let query = "a".toString()
        let contents = "apple\napricot\navocado".toString()

        var config = Config {
            query,
            filename: "test.txt".toString(),
            ignoreCase: false,
        }

        let results = search(&config, &contents)

        assertEq!(results.len(), 3)
        assertEq!(results[0], "apple".toString())
        assertEq!(results[1], "apricot".toString())
        assertEq!(results[2], "avocado".toString())
    }
}

Testing Argument Parsing

Test the Config struct:

#[cfg(test)]
module configTests {
    import super.*

    #[test]
    fn requiredArguments() {
        let args = vec!["oxgrep".toString()]
        let result = Config.new(&args)

        assert!(result.isErr())
    }

    #[test]
    fn parsesQueryAndFilename() {
        let args = vec![
            "oxgrep".toString(),
            "test".toString(),
            "file.txt".toString(),
        ]
        let result = Config.new(&args)

        assert!(result.isOk())

        let config = result.unwrap()
        assertEq!(config.query, "test".toString())
        assertEq!(config.filename, "file.txt".toString())
        assert!(!config.ignoreCase)
    }

    #[test]
    fn parsesIgnoreCaseFlag() {
        let args = vec![
            "oxgrep".toString(),
            "test".toString(),
            "file.txt".toString(),
            "--ignore-case".toString(),
        ]
        let result = Config.new(&args)

        assert!(result.isOk())

        let config = result.unwrap()
        assert!(config.ignoreCase)
    }
}

Testing With Temporary Files

For more integration-like tests, use temporary files:

import std.fs
import std.path.PathBuf
import std.env

fn createTempFile(contents: &str): Result<PathBuf, Box<dyn Error>> {
    let tempDir = std.env.tempDir()
    let filename = tempDir.join("oxgrep_test.txt")

    std.fs.write(&filename, contents)?

    Ok(filename)
}

#[cfg(test)]
module integrationTests {
    import super.*

    #[test]
    fn searchesRealFile(): Result<(), Box<dyn Error>> {
        let filename = createTempFile("apple\napricot\navocado")?

        var config = Config {
            query: "a".toString(),
            filename: filename.toString(),
            ignoreCase: false,
        }

        let contents = std.fs.readToString(&config.filename)?
        let results = search(&config, &contents)

        assertEq!(results.len(), 3)

        // Cleanup
        std.fs.removeFile(&filename)?

        Ok(())
    }

    #[test]
    fn handlesNonExistentFile(): Result<(), Box<dyn Error>> {
        var config = Config {
            query: "test".toString(),
            filename: "nonexistent.txt".toString(),
            ignoreCase: false,
        }

        let result = run(&config)

        assert!(result.isErr())

        Ok(())
    }
}

Running Tests

Run all tests:

cargo test

Output:

running 7 tests
test configTests.parsesIgnoreCaseFlag ... ok
test configTests.parsesQueryAndFilename ... ok
test configTests.requiredArguments ... ok
test integrationTests.handlesNonExistentFile ... ok
test integrationTests.searchesRealFile ... ok
test tests.caseInsensitive ... ok
test tests.caseSensitive ... ok
test tests.multipleMatches ... ok
test tests.noMatches ... ok

test result: ok. 9 passed; 0 failed

Testing Error Conditions

Test that errors are handled gracefully:

#[test]
fn invalidArguments() {
    let args = vec!["oxgrep".toString(), "query".toString()]
    let result = Config.new(&args)

    assert!(result.isErr())

    match result {
        Err(msg) -> {
            assert!(msg.contains("not enough arguments"))
        },
        _ -> panic!("Expected error"),
    }
}

#[test]
fn emptySearch() {
    let query = "".toString()
    let contents = "Line 1\nLine 2\nLine 3".toString()

    var config = Config {
        query,
        filename: "test.txt".toString(),
        ignoreCase: false,
    }

    let results = search(&config, &contents)

    // Empty query matches all lines (contains("") is true)
    assertEq!(results.len(), 3)
}

#[test]
fn emptyFile() {
    let query = "test".toString()
    let contents = "".toString()

    var config = Config {
        query,
        filename: "test.txt".toString(),
        ignoreCase: false,
    }

    let results = search(&config, &contents)

    assertEq!(results.len(), 0)
}

Test Organization Best Practices

  1. Group related tests - Use modules to organize test functions
  2. Descriptive names - Make test purpose clear from the name
  3. Test one thing - Each test should verify one behavior
  4. Use fixtures - Create helper functions for common setup
  5. Test edge cases - Empty input, large input, invalid input
  6. Integration tests - Test the complete flow
  7. Document assumptions - Explain why tests work as they do

Running Specific Tests

# Run tests matching a name
cargo test case_insensitive

# Run tests in a specific module
cargo test configTests

# Run with output
cargo test -- --nocapture

# Run one test per thread (slower but shows output in order)
cargo test -- --test-threads=1

Summary

Effective testing for CLI programs:

  • Separate concerns - Move logic into a library for easier testing
  • Unit tests - Test individual functions with test data
  • Integration tests - Test complete workflows with real files
  • Error cases - Verify graceful failure handling
  • Assertions - Use assert!, assertEq!, and assertNe!
  • Organization - Group tests logically in modules

Next, we'll refactor our code for better performance and maintainability.