Mojo Language Testing: Unit Tests, Assertions, and Mocking with TestSuite

The Modular Mojo language ships with a testing module that looks deceptively simple — until you try to run your first suite and realize the entry points changed, the old runner is gone, and the documentation is three versions behind.

This guide covers how Mojo language testing actually works in 2026: what the std.testing module gives you, how to structure real test files, and how to get meaningful PASS / FAIL output from compiled test binaries.

Mojo language testing is fundamentally different from Python’s pytest model. Tests are compiled, statically typed, and run as standalone executables — not interpreted scripts walked by a framework at runtime.


TL;DR

  • The mojo test command was removed in October 2026; use mojo run test_file.mojo instead.
  • Every Mojo language test file needs a main() entry point and a test_ prefix by convention.
  • Import the mojo stdlib testing module via from std.testing import ... for all assertion helpers.
  • TestSuite handles test discovery via __functions_in_module() or manual suite.test() registration.
  • Mojo language has no built-in mock library — use struct-based dependency injection instead.
  • The pytest-mojo plugin integrates .mojo files into standard pytest pipelines.
  • Mojo test output uses three states: PASS, FAIL, and skipped.

How the Mojo Language Testing Framework Works in 2026

Mojo language testing is built around a single stdlib module: std.testing. There is no external runner, no plugin system at the core level, and no decorator magic borrowed from Python.

What you get is a compiled, statically-typed suite runner. The Modular Mojo testing architecture treats tests as regular compiled code — discovery, assertion logic, and suite orchestration all happen inside the compiled binary.

That model is fundamentally different from pytest, where a runner walks your filesystem at runtime, imports modules dynamically, and collects test functions via reflection. In Mojo language testing, everything is resolved at compile time.

How the mojo stdlib Testing Module Is Structured

One import line unlocks the full assertion surface for Mojo language testing:


from std.testing import (
 TestSuite,
 assert_equal,
 assert_true,
 assert_false,
 assert_almost_equal,
 assert_raises,
 assert_equal_pyobj,
)

TestSuite is the orchestration layer. The assertion functions are standalone — you can call assert_true or assert_false outside a suite for quick smoke checks, though that pattern does not scale past throwaway scripts.

The mojo stdlib testing module is stable as of Mojo 1.0. Function signatures and TestSuite behavior are now covered by the language stability guarantee.

Why the mojo test Command Was Removed in October 2026

Until mid-2026, Modular shipped a dedicated mojo test subcommand that handled test discovery, parallel execution, and output formatting. It was pulled in the October 2026 toolchain update.

The reasoning: mojo test performed discovery outside the compiled artifact. That caused non-obvious failures when test files imported compiled modules that had not been rebuilt since the last source change.

The replacement is mojo run test_file.mojo. Each test file is a self-contained executable. You call TestSuite.run() method inside main(), and that is your runner — no external tooling required.

CI pipelines that used mojo test ./tests/ need updating. A shell glob works cleanly: for f in tests/test_*.mojo; do mojo run "$f"; done. The pytest-mojo plugin handles this automatically if you are already on a pytest stack.

How Test Discovery Works in Compiled Mojo Language Testing

Test discovery in Mojo language testing relies on the __functions_in_module() compiler intrinsic. When you call suite.discover_tests(), it uses this intrinsic to enumerate all functions in the current module whose names begin with test_.

This is a compiled behavior, not a dynamic one. The intrinsic resolves at compile time — there is no runtime filesystem walk, no import hook, and no module loading side effects. The test list is fixed when the binary is built.

That distinction matters for Modular Mojo testing: you cannot add test functions conditionally based on runtime state and expect discovery to pick them up. If conditional test registration is needed, use manual suite.test() calls inside main() where you can branch on runtime values.


# test discovery mojo compiled — uses __functions_in_module() intrinsic
fn main() raises:
 var suite = TestSuite("math_utils")
 suite.discover_tests() # auto-discovers all test_ functions in module
 suite.run()

Writing Mojo Language Tests: the test_ Prefix and main()

In Modular Mojo, a test file is structurally identical to any other source file. It just needs a main() function as its entry point. The test_ prefix on the filename is a convention that tools like pytest-mojo rely on for file-level discovery.

Mojo language tests written as fn functions must carry the raises keyword. Assertion failures propagate as errors, so the test function signature must declare that it can raise — otherwise the compiler refuses to compile the call.


# test_math_utils.mojo
from std.testing import TestSuite, assert_equal
from myapp.math_utils import add, multiply

# test function module scope — fn with raises keyword
fn test_add_positive() raises:
 assert_equal(add(2, 3), 5)

fn test_multiply_by_zero() raises:
 assert_equal(multiply(99, 0), 0)

fn main() raises:
 var suite = TestSuite("math_utils")
 suite.discover_tests()
 suite.run()
 # mojo test output PASS FAIL skipped printed per test

def vs fn in Mojo Language Test Functions

Both def and fn work for test functions in Mojo language testing, but they behave differently.

def functions are implicitly raises-compatible and have Python-like argument passing semantics. Variables inside def are implicitly var-declared, and arguments are passed by implicit copy.

Deep Dive
Mojo Pitfalls Manual: MojoWiki

Beyond the Hype: The Unofficial MojoWiki for Production-Grade Engineering Mojo ships with a pitch that's hard to ignore: Python syntax, C-level performance, and MLIR power under the hood. While the Mojo programming language is a...

fn functions require explicit raises annotation, have strict ownership semantics, and give the compiler more information to optimize. The convention in Mojo language testing is fn with explicit raises — it keeps test code consistent with production code style and makes error propagation visible in the signature.

TestSuite.discover_tests() picks up both def and fn functions that match the test_ prefix. Use fn for new test files unless you have a specific reason to use def.

TestSuite.discover_tests vs Manual suite.test() Registration

Automatic discovery via __functions_in_module() is the default. It scans the module at compile time and registers everything with a test_ prefix. No manual wiring required.

Manual registration with suite.test() gives you explicit ordering and custom display names — useful when test output feeds a CI dashboard that parses test names, or when certain tests must run before others for dependency reasons.


# manual registration — explicit order, custom labels
fn main() raises:
 var suite = TestSuite("payments")
 suite.test("create order", test_create_order)
 suite.test("charge card", test_charge_card)
 suite.run()

The tradeoff with manual registration: you must remember to add every new test function to the list. In large files this creates drift. For most Modular Mojo testing scenarios, automatic discovery is the right default.

All Mojo Language Testing Assertion Functions Explained with Code Examples

The mojo stdlib testing module covers the standard assertion surface without over-engineering it. The following table summarizes core Mojo language testing assertion functions, their use cases, and when they raise.

Function Use Case Raises On
assert_equal(a, b) Exact equality for integers, strings, structs a != b
assert_almost_equal(a, b) Float comparison with epsilon tolerance |a – b| > epsilon
assert_true(expr) Boolean guard checks expr == False
assert_false(expr) Negative condition checks expr == True
assert_raises(fn) Verifies a function raises an error No error raised
assert_equal_pyobj(a, b) Comparing PythonObject return values Python-level inequality

assert_equal vs assert_almost_equal: When to Use Each

In Mojo language testing, use assert_equal for anything with exact equality semantics: integer math, string outputs, enum values, struct field comparisons.

The moment floating-point arithmetic enters the picture, switch to assert_almost_equal. IEEE 754 rounding means that 1.0 / 3.0 * 3.0 is not exactly 1.0 in binary representation. Using assert_equal on floats produces false failures that are hard to debug.


# exact — safe for integers and strings
assert_equal(parse_status_code("200 OK"), 200)

# float — use epsilon to avoid IEEE 754 false failures
assert_almost_equal(compute_ratio(1.0, 3.0), 0.3333, atol=1e-4)

assert_true and assert_false in Mojo Language Testing

assert_true and assert_false are the boolean guards in Mojo language testing. They work on any expression that evaluates to a Bool.

Use assert_true when you need to verify a condition holds — presence checks, flag states, comparison results that don’t map cleanly to equality. Use assert_false for the inverse: confirming something is absent or inactive.


# assert_true assert_false mojo — boolean condition checks
fn test_user_active_flag() raises:
 var user = create_user(active=True)
 assert_true(user.is_active)
 assert_false(user.is_banned)

Testing fn Functions That Require the raises Keyword

This catches developers coming from Python. In Mojo language, fn functions that can throw must be annotated with raises. Your test function calling them must carry the same annotation.

Without it, the compiler refuses to compile the call — you cannot call a raises-annotated function from a non-raising context. This is enforced at compile time, not discovered at runtime.


# function under test declares raises
fn parse_config(path: String) raises -> Config:
 # may raise if file doesn't exist
 ...

# test function must also declare raises
fn test_parse_config_valid() raises:
 var cfg = parse_config("./fixtures/valid.toml")
 assert_equal(cfg.port, 8080)

assert_raises with contains Argument: Checking Error Messages

Verifying that code raises something is weak coverage. The contains argument on assert_raises lets you match a substring in the error message.

This confirms the right error path fired — not just that any error occurred. If the function raises but the message does not contain the expected substring, the assertion still fails.


# error message substring assert — confirms the right error path
fn test_invalid_port_raises() raises:
 assert_raises(
 fn() raises => parse_config("./fixtures/bad_port.toml"),
 contains="invalid port"
 )

assert_equal_pyobj: Testing Functions That Return PythonObject

Modular Mojo’s Python interop layer wraps Python values in PythonObject. Regular assert_equal does not compare them correctly because equality semantics are Python-side, not Mojo-side.

Use assert_equal_pyobj whenever a function under test returns a PythonObject — dictionaries, lists, or any value coming back through the Python interop bridge.


from python import Python
from std.testing import assert_equal_pyobj

fn test_python_dict_output() raises:
 var result = call_python_pipeline()
 var expected = Python.evaluate("""{"status": "ok", "count": 3}""")
 assert_equal_pyobj(result, expected)

How to Skip Mojo Language Tests with suite.skip()

Skipping in Mojo language testing is runtime, not compile-time. The suite.skip() call registers a test as skipped before suite.run() executes.

Mojo Test Output States: PASS / FAIL / Skipped

Skipped tests appear in the mojo test output PASS FAIL skipped format. This format is consistent across all execution methods — whether you run via mojo run or the pytest-mojo plugin — and is easy to parse in CI pipelines without missing skipped tests.


fn main() raises:
 var suite = TestSuite("gpu_ops")

 if not gpu_available():
 # conditional skip — mojo test output PASS FAIL skipped
 suite.skip("test_tensor_matmul", reason="no GPU in environment")
 else:
 suite.test("test_tensor_matmul", test_tensor_matmul)

 suite.run()

The skip decision happens at runtime, so you can gate it on environment variables, feature flags, or any boolean your system exposes. This makes Mojo language tests conditionally skippable without patching files between environments.

In a pixi mojo project test setup, you can expose environment flags via pixi.toml task definitions and read them inside main() to drive skip logic consistently across developer machines and CI.

Technical Reference
Mojo Internals

Mojo Internals: Why It Runs Fast Mojo is often introduced as a language that combines the usability of Python with the performance of C++. However, for developers moving from interpreted languages, the reason behind its...

Mocking in Mojo Language Testing: Faking Dependencies Without a Library

There is no mock library in the mojo stdlib testing module. No unittest.mock, no proxy generation, no monkey-patching API. Modular Mojo testing relies on struct-based dependency injection instead.

This sounds limiting until you realize that Mojo’s trait system gives you something stronger: compile-time-verified fake implementations. If your fake struct does not implement the full trait, it will not compile. Wrong mocks fail at build time, not during a production incident.

How to Simulate Setup and Teardown Without Built-in Hooks

Mojo language testing has no setUp / tearDown lifecycle hooks. The workaround is manual: call setup logic at the top of each test function, and invoke teardown explicitly before the final assertions.


fn test_cache_writes() raises:
 # manual setup — no built-in lifecycle hooks in Mojo language testing
 var cache = FakeCache.new()
 populate_test_data(cache)

 # exercise the system under test
 write_session(cache, user_id=42, data="payload")

 # assert
 assert_true(cache.has_key("session:42"))

 # manual teardown
 cache.flush()

Passing a Fake Struct as a Function Argument Instead of Mocking

The idiomatic pattern in Mojo language testing is to define a trait for your dependency, implement it in production code, then implement a lightweight fake for tests. The fake is injected as a function argument — no runtime patching required.


# define the interface as a trait
trait EmailSender:
 fn send(self, to: String, body: String) raises

# real production implementation
struct SmtpSender(EmailSender):
 fn send(self, to: String, body: String) raises:
 # actual SMTP call
 ...

# fake for Mojo language testing — no network, records calls
struct FakeSender(EmailSender):
 var sent_to: List[String]

 fn send(self, to: String, body: String) raises:
 self.sent_to.append(to) # just record, do not send

fn test_welcome_email_sent() raises:
 var sender = FakeSender(sent_to=List[String]())
 register_user("alice@example.com", sender)
 assert_equal(sender.sent_to[0], "alice@example.com")

This pattern works because Mojo’s trait system enforces the contract at compile time. The fake struct satisfies the same type constraint as the real implementation.

Compared to Python’s unittest.mock.patch, Modular Mojo testing via struct injection has no runtime proxy generation, no mismatched method signatures discovered at test execution, and no silent pass when a mock is misconfigured.

Running Mojo Language Tests with mojo run vs pytest-mojo Plugin

You have two realistic paths for running Mojo language tests in 2026. The mojo run approach is zero-configuration and works anywhere the Mojo toolchain is installed. The pytest-mojo plugin integrates into existing Python test infrastructure.

For purely Mojo language projects, mojo run is all you need. For mixed Mojo/Python codebases — which covers most production Modular Mojo testing setups right now — the plugin earns its one-line install cost.

Separating Test Files from Source Files in a Mojo Project

Keep test files in a dedicated tests/ directory. This matters for Mojo language tests specifically because compiled artifacts from source files can end up in the same directory during builds, causing import resolution issues.


myapp/
 src/
 math_utils.mojo
 config.mojo
 tests/
 test_math_utils.mojo # test_ prefix for discovery
 test_config.mojo
 pixi.toml

In a pixi mojo project test configuration, define a test task in pixi.toml that runs the glob: for f in tests/test_*.mojo; do mojo run "$f"; done. This gives consistent behavior across developer machines and CI without shell script fragmentation.

How pytest-mojo Plugin Discovers and Runs .mojo Test Files

The pytest-mojo plugin hooks into pytest’s collection phase. It looks for files matching the test_*.mojo pattern, compiles each one via mojo run, and parses the mojo test output PASS FAIL skipped back into pytest’s result tree.


# install the plugin
pip install pytest-mojo

# run — discovers both .py and .mojo test files
pytest tests/

# output maps .mojo results into standard pytest format
# PASSED tests/test_math_utils.mojo::test_add_positive
# FAILED tests/test_config.mojo::test_invalid_port_raises
# SKIPPED tests/test_gpu_ops.mojo::test_tensor_matmul

The plugin delegates execution to mojo run under the hood. What it adds is integration with pytest fixtures, coverage tools, and CI reporters that already parse pytest output.

One gotcha: the plugin requires the mojo binary on PATH in whatever environment runs pytest. In Docker-based CI, your test image needs the Modular Mojo toolchain installed alongside Python — factor that into build stages.

Worth Reading
Mojo: Python, but 100x...

Mojo for Python developers Python has dominated the software world due to its high-level syntax and ease of use, but it has always been shackled by a massive bottleneck: performance. For years, the "two-language problem"...

Mojo Benchmark Testing Difference: Tests vs Benchmarks

The Modular Mojo stdlib ships a benchmark module that is separate from the mojo stdlib testing module. Understanding the mojo benchmark testing difference is important for keeping test suites meaningful.

Unit tests via std.testing produce PASS / FAIL / skipped output and are designed to verify correctness. Benchmarks measure throughput and latency — they produce timing data, not correctness verdicts.

Benchmarks do not belong in tests/. Keep them in a separate benchmarks/ directory and run them explicitly, never as part of a standard CI pass. Mixing them inflates test suite runtime and obscures correctness signal.

FAQ: Mojo Language Testing

Can I use def instead of fn for test functions in Mojo language testing?

Yes, but keep in mind that def functions are less strict. They implicitly handle raises and copy arguments like Python. TestSuite.discover_tests() will detect them correctly.
For production-quality tests, it is recommended to use fn with explicit raises to maintain code consistency and avoid PR issues.

How do I run a single test function without running the whole suite?

TestSuite.run() does not include a built-in single-test filter.
To run a single test, either manually register it via suite.test("my_test", my_test) after commenting out auto-discovery, or use pytest-mojo with the standard Python -k flag to filter tests.

Does Mojo language testing support parallel test execution?

Tests within a single suite.run() are executed serially.
To achieve parallelism, split tests across multiple files and run concurrent mojo run processes in CI, or use pytest-mojo with pytest-xdist for file-level parallel execution.

Is the Mojo language testing API stable in Mojo 1.0?

The API is considered stable as of Mojo 1.0.
The removal of mojo test in October 2025 was the last major change. Assertion signatures and TestSuite.run() are now backward-compatible.

How do Modular Mojo testing assertions compare to Python unittest?

Mojo assertions are stricter than Python’s assertEqual.
assert_equal requires both types to implement the Equatable trait. Comparing mismatched types will cause a compile-time error, unlike Python where errors occur only at runtime.

How do I configure a pixi mojo project test task?

Define the test execution command in the tasks section of pixi.toml.
Pixi handles the Mojo toolchain and Python dependencies like pytest-mojo, ensuring consistent execution in local and CI environments without separate shell scripts.

What is test function module scope in Mojo?

Test functions are top-level, not part of a class hierarchy like unittest.TestCase.
The compiler uses __functions_in_module() to detect all functions prefixed with test_ and compiles them into the executable. This simplifies test discovery and execution.

Conclusion: What Matters in Mojo Language Testing

Mojo language testing in 2026 is leaner than many Python developers might expect. Theres no runner daemon, no plugin ecosystem baked in, and no auto-retry or parameterization out of the box.
But thats okay — what you get is small, compiled, and honest about what it does. The std.testing module handles the 90% case cleanly, and struct-based mocking replaces runtime monkey-patching with compile-time contracts. Moving from mojo test to mojo run cuts out the indirection that used to confuse more than it helped.

Test output — PASS, FAIL, and skipped — is simple and predictable. Whether you run your files directly via mojo run or through the pytest-mojo plugin, you can parse results in CI without headaches.

Sure, Modular Mojo testing is still thin in some areas: no property-based testing, no snapshot assertions, and no built-in parameterization yet. But thats fine — the languages compile-time safety and the explicit raises contract give you something Python frameworks cant: tests that fail loudly at compile time instead of sneaking past at runtime.

If Im working on a pure Mojo project, the native suite runner is all I need. For mixed codebases — which is most production setups — pytest-mojo fits right in, keeping testing infrastructure consistent.
Once you grasp how Mojos compiled test discovery differs from Pythons dynamic collection, the rest clicks. The languages ownership-based design makes the whole testing experience logical and predictable, and honestly, a bit satisfying.

Written by:

Source Category: Mojo Language