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 testcommand was removed in October 2026; usemojo run test_file.mojoinstead. - Every Mojo language test file needs a
main()entry point and atest_prefix by convention. - Import the mojo stdlib testing module via
from std.testing import ...for all assertion helpers. TestSuitehandles test discovery via__functions_in_module()or manualsuite.test()registration.- Mojo language has no built-in mock library — use struct-based dependency injection instead.
- The
pytest-mojoplugin integrates.mojofiles 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.
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.
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.
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
deffunctions are less strict. They implicitly handleraisesand copy arguments like Python.TestSuite.discover_tests()will detect them correctly.
For production-quality tests, it is recommended to usefnwith explicitraisesto 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 viasuite.test("my_test", my_test)after commenting out auto-discovery, or usepytest-mojowith the standard Python-kflag 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 concurrentmojo runprocesses in CI, or usepytest-mojowithpytest-xdistfor 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 ofmojo testin October 2025 was the last major change. Assertion signatures andTestSuite.run()are now backward-compatible. -
How do Modular Mojo testing assertions compare to Python unittest?
- Mojo assertions are stricter than Python’s
assertEqual.
assert_equalrequires both types to implement theEquatabletrait. 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
taskssection ofpixi.toml.
Pixi handles the Mojo toolchain and Python dependencies likepytest-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 withtest_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: