Understanding Minitest Architecture

TestCase Inheritance and Setup Flow

All Minitest unit tests inherit from Minitest::Test. Each test runs within a fresh instance of the test class, with setup and teardown methods invoked before and after each test method, respectively.

Spec DSL and Global State

Minitest::Spec provides an RSpec-like syntax. Misuse of global variables or improperly scoped let-style methods can cause tests to share state inadvertently, leading to flakiness or inconsistent results.

Common Symptoms

  • Tests fail intermittently when run together but pass individually
  • Mocks or stubs leaking into other tests
  • Test suite slows down significantly as project grows
  • Random test execution order causing false positives/negatives
  • Unexpected output or side-effects from shared setup logic

Root Causes

1. Test Pollution via Shared State

Class variables or global variables used for setup data persist across tests. This violates test isolation and makes outcomes dependent on test execution order.

2. Improper Teardown of Mocks

Mocks created via stub or MiniTest::Mock must be properly reset. Forgetting to verify or restore them causes contamination across test cases.

3. Heavy Fixtures or Database Setup

Overuse of complex fixtures or lack of transactional tests results in bloated test execution and non-deterministic behavior.

4. Non-deterministic Time or Random Logic

Tests that depend on time or random numbers without stubbing produce inconsistent results. Failing to freeze time or seed randomness leads to flakiness.

5. Random Test Order and Unordered Dependencies

Minitest randomizes test execution order by default. Hidden dependencies between tests surface when order changes, especially under CI environments.

Diagnostics and Monitoring

1. Enable Verbose Output

ruby -Ilib:test test/my_test.rb --verbose

Reveals test execution order and helps pinpoint the first failing test in suites with cascading failures.

2. Run with Seed Logging

ruby -Ilib:test test/my_test.rb --seed 12345

Reproduces test execution order to isolate interdependencies. Capture the seed for consistent debugging.

3. Use Test Profiler

Track slow tests using gems like minitest-reporters or custom hooks that log execution times. Identify bottlenecks in slow or expensive setup phases.

4. Log Setup/Teardown Scope

Add logging to setup and teardown methods to ensure resources are cleaned up properly and not affecting other tests.

5. Isolate Stubs and Mocks

Wrap mocking logic in begin/rescue blocks to verify they don't bleed across test cases.

Step-by-Step Fix Strategy

1. Eliminate Shared State

Use instance variables initialized in setup instead of class variables or constants. Reset all mutated objects between tests.

2. Verify and Restore Mocks

mock = Minitest::Mock.new
mock.expect(:foo, :bar)
assert_equal :bar, mock.foo
mock.verify

Always call verify after mocks and wrap them in teardown if needed to ensure cleanup.

3. Optimize Fixtures and Use Factories

Replace slow fixtures with factory objects built via FactoryBot or OpenStruct. Use transactional tests to clean up state.

4. Freeze Time and Seed Randomness

Time.stub :now, Time.new(2024, 1, 1) do
  # test code
end

Use Kernel.srand with a fixed seed to control randomness in tests.

5. Refactor Tests to Remove Order Dependencies

Always assume each test runs independently. Use seed flags and CI to expose flaky test dependencies early in the pipeline.

Best Practices

  • Use transactional fixtures or database cleaning per test
  • Prefer factory methods over shared fixtures
  • Write pure unit tests—minimize reliance on external state
  • Stub time, randomness, and environment-specific data
  • Enforce mock verification and teardown across all tests

Conclusion

Minitest is a flexible and fast Ruby testing framework, but its simplicity can lead to overlooked edge cases in state management and test isolation. With clear mock handling, controlled randomness, proper use of setup/teardown, and runtime diagnostics, teams can build resilient and reliable test suites. A well-maintained Minitest setup not only accelerates feedback cycles but also strengthens application confidence in CI/CD pipelines.

FAQs

1. Why do my Minitest tests pass locally but fail in CI?

Test order randomization, database state differences, or environment-dependent variables may cause non-deterministic results. Use fixed seeds and isolated fixtures.

2. How can I test for raised exceptions in Minitest?

Use assert_raises with the expected exception class and block syntax to test exception behavior.

3. What’s the difference between setup and before in Minitest?

setup is used in Minitest::Test; before is used in Minitest::Spec. Both serve to prepare test context but follow different DSLs.

4. Can I parallelize Minitest tests?

Yes, using parallelize_me! or external tools like parallel_tests. Ensure tests don’t share resources or database connections.

5. How do I mock a method only during one test?

Use Object.stub(:method, return_value) { ... } within the test block to apply scoped mocking.