Skip to main content

Testcontainers Blur the Lines Between Unit and Integration Tests

· 4 min read
Ben Abbitt
Consultant, Software Architect, AI Wrangler
About This Blog

This blog documents learnings from building Angzarr—a polyglot event sourcing framework. The framework core is written in Rust, so examples here are primarily Rust.

Angzarr doesn't require Rust. Client SDKs exist for Go, Python, Java, C#, and C++. The author—a polyglot developer—doesn't believe Rust is the best language for everything. It is the right choice for this framework's core, and building it has produced these learnings.

The Rust should be readable by most programmers. If you have questions: consult The Rust Book, ask an LLM, or email the author.

The old unit/integration distinction assumed "integration" meant "slow, fragile, needs environment setup." Testcontainers changed the economics.

The Traditional Divide

We used to draw a hard line between unit tests and integration tests:

  • Unit tests: Fast, no external dependencies, run anywhere, colocate with code
  • Integration tests: Slow, need databases/queues/services, run in CI, separate directory

This separation made sense when "integration test" meant "spin up a full environment." You wouldn't colocate tests that require PostgreSQL next to your repository implementation; they'd fail on every developer's machine without the right setup.

Testcontainers Changed This

Testcontainers (in Rust: testcontainers-rs) spins up real infrastructure in Docker containers, on demand, per test.

#[test]
fn bit_event_store_persists_events() {
let container = PostgresContainer::new();
let pool = connect_to(&container);
let store = PostgresEventStore::new(pool);

store.append("order-123", vec![event]).unwrap();
let events = store.read("order-123").unwrap();

assert_eq!(events.len(), 1);
}

This test spins up a real PostgreSQL instance in Docker, runs the test against it, and tears it down. No shared database. No environment configuration. No "works on my machine." The container is ephemeral, isolated, and automatic.

Behavioral Interface Tests (BITs) Fit Here

We call these Behavioral Interface Tests (BITs): tests that verify an implementation correctly fulfills its interface's behavioral contract. Tests that verify trait implementations (EventStore, SnapshotStore, MessageBus) are BITs—not "integration tests" in the traditional sense.

These tests should live near the implementation:

src/
├── storage/
│ ├── postgres.rs # PostgresEventStore implementation
│ ├── postgres.bit.rs # BITs against real Postgres
│ ├── sqlite.rs
│ └── sqlite.bit.rs

The "real database" aspect doesn't change where the test belongs. It's still testing one module's behavior. It's still colocated. It just happens to need a container.

(Why "BIT"? It's a pun. "The BIT caught a regression." "That edge case BIT me." Also: Behavioral Interface Test.)

The New Distinction: Scope, Not Speed

The old unit/integration split was about how tests run. The better distinction is what they test.

Test TypeWhat It TestsWhere It Lives
UnitPure logic, no dependenciesAdjacent .test file
BITSingle implementation against its interfaceAdjacent .test file (with testcontainers)
IntegrationMultiple components interactingtests/ directory
End-to-endFull system behaviorSeparate test project

BITs with testcontainers are closer to unit tests than integration tests. They test one thing. They're fast enough to run frequently. They should be colocated.

See Martin Fowler's Practical Test Pyramid for more on scope-based test categorization.

The CI Consideration

Yes, testcontainer tests are slower than pure unit tests. On my machine, a PostgreSQL container adds ~2 seconds of startup. That's too slow for "run on every save" but fine for "run before commit."

We handle this with test categories:

#[test]
fn test_pure_logic() { /* runs always */ }

#[test]
#[cfg_attr(not(feature = "testcontainers"), ignore)]
fn test_postgres_storage() { /* runs with --features testcontainers */ }

Local development runs the fast tests continuously. Pre-commit hooks (we like Lefthook) and CI run everything. The slower tests are still colocated; they're just conditionally executed.

Mocks Are for Boundaries, Not Implementations

This shift changed how I think about mocking. Previously, I'd mock the database to test repository logic. Now I test the repository against a real database (via testcontainers) and reserve mocks for:

  • External services I don't control (third-party APIs)
  • Failure injection (simulate network errors)

If I can test against the real thing cheaply, I should. Testcontainers made "the real thing" cheap.

The Takeaway

The unit/integration distinction was always about economics: unit tests were cheap, integration tests were expensive. Testcontainers collapsed that cost difference for many scenarios.

When the economics change, the categories should too. BITs against real infrastructure aren't integration tests just because they touch a database. They're colocatable, fast-enough, single-purpose tests that happen to need Docker.

Organize by what you're testing, not by what tools you need to test it.


Prior art: This concept aligns with what some call "Behavioral Contract Testing" (jdecool.fr) and the Abstract Test pattern (testingpatterns.net). We prefer "BIT" because it's punchier and avoids confusion with Consumer-Driven Contract testing (Pact, etc.).