What module-centric testing is and why the classic test pyramid fits poorly
In module-centric testing, the module boundary is the main unit you test. The class is too small and the whole deployable is too big. A test calls the module through its public surface and asserts on what comes back: return values, persisted state, and published events. It never reaches into internal classes or private tables.
The classic test pyramid assumes the class is the natural unit. In a modular monolith, a unit-heavy suite mostly tests a module's internal structure. Rename a service, split a repository or merge two handlers, and dozens of tests break even though no caller sees a difference. That makes refactoring inside a module expensive, which defeats the point of having modules.
Full-application tests have the opposite problem. They boot every module, so they run slowly. When one fails, it is not obvious which team should investigate, because the failure could sit in any module along the request path.
The resulting shape is widest at the module level. Thin layers of unit, boundary-contract and end-to-end tests surround it:
| Test layer | Unit under test | Breaks when | Share of suite |
|---|---|---|---|
| Unit | Single class or function | Internal code changes | Small, for dense logic |
| Module | One module via its public API | Module behaviour changes | Largest |
| Contract | Interface between two modules | A boundary agreement changes | Small |
| End-to-end | Whole deployable | Wiring or a critical path breaks | Very small |
This approach suits backend and platform teams that run a single deployable with explicit modules, where different people or teams own different modules. Each owner can then treat a module's tests as its specification.
E2E tests (very few): boot every module, exercise critical paths
|
v
+--------------------------------------------------------------------------+
| Single deployable (one process, many owned modules) |
| |
| +---------------------------+ +---------------------------+ |
| | Orders module (Team A) | | Billing module (Team B) | |
| | +---------------------+ | | +---------------------+ | |
| | | Public API | |-contract->| | Public API | | |
| | +---------------------+ | | +---------------------+ | |
| | | internal classes | | | | internal classes | | |
| | | private tables | | | | private tables | | |
| | +---------------------+ | | +---------------------+ | |
| +---------------------------+ +---------------------------+ |
+--------------------------------------------------------------------------+
^ ^ ^ ^
| | | |
Unit Module Contract Module
(few, dense (largest, (thin, one per (largest,
internals) Team A owns) module boundary) Team B owns)
Module-centric testing inside one deployable: module tests drive each module through its public API and form the largest layer, while a few unit tests cover dense internals, contract tests pin the boundary between modules, and a small set of end-to-end tests exercises the whole process.
Defining a module's public surface and testing only through it
A module's public surface is the set of entry points other modules are allowed to use: commands that change state, queries that read it, events it publishes, and any facade that groups those operations. Everything else is internal, including repositories, ORM entities, domain services and helper classes, even if the language's visibility rules would let outside code reach them. Declare the surface explicitly, for example as one package or namespace per module, so reviewers and tooling can both tell what is public.
Module tests use only that surface. They set up state by issuing commands, not by inserting rows through a repository. They assert through queries and captured events, not by reading tables or inspecting private fields. Because each test describes behavior that other modules depend on, you can split a class, reshape a schema or replace an aggregate without editing the test. If a refactor does break a module test, the refactor changed observable behavior, and that is what you want the suite to tell you.
As an example, take a test for an Orders module. It sends a PlaceOrder command with two line items. It then runs the GetOrder query and checks that the status is pending and the total is correct. Finally, it checks an in-memory event recorder for exactly one OrderPlaced event that carries the order ID and total. No step in the test refers to the order repository or the pricing class.
The trade-off is weaker coverage of complex internal logic. A pricing-rule engine or a scheduling algorithm has many input combinations, and surface tests exercise only a few of them. When a surface test fails, it points to the module rather than to the faulty line. Those algorithms still need focused unit tests, which section 5 covers.
import { test } from "node:test";
import assert from "node:assert/strict";
// Import only from the module's public entry point, never from orders/internal/*.
import { createOrdersModule, PlaceOrder, GetOrder, OrderPlaced } from "../src/orders";
import { InMemoryEventRecorder } from "../test-support/in-memory-event-recorder";
test("placing an order makes it queryable and publishes OrderPlaced", async () => {
// Wire the module with an in-memory event bus so published events can be inspected.
const events = new InMemoryEventRecorder();
const orders = createOrdersModule({ eventBus: events });
// Arrange and act through a command, not by inserting rows via a repository.
const { orderId } = await orders.execute(
new PlaceOrder({
customerId: "cust-42",
lines: [
{ sku: "SKU-RED-MUG", quantity: 2, unitPriceCents: 1000 },
{ sku: "SKU-TEA-BOX", quantity: 1, unitPriceCents: 1500 },
],
}),
);
// Assert state through a query, not by reading tables or private fields.
const order = await orders.query(new GetOrder(orderId));
assert.equal(order.status, "pending");
assert.equal(order.totalCents, 3500);
// Assert the integration contract: exactly one OrderPlaced with the ID and total.
const placed = events.ofType(OrderPlaced);
assert.equal(placed.length, 1);
assert.equal(placed[0].orderId, orderId);
assert.equal(placed[0].totalCents, 3500);
});
A module test for Orders that issues a PlaceOrder command, then verifies the outcome only through the GetOrder query and a captured OrderPlaced event, without touching the repository or pricing internals.
Running module tests against real infrastructure without making them slow
A module test runs against the same database engine you use in production, started in a container once per test run rather than once per test. In-memory substitutes look faster, but they accept SQL the real engine rejects and skip behaviour the real engine enforces: constraint timing, locking, collation, JSON operators. A module test exists to catch exactly those problems. Give each module its own schema or table prefix so that its tests touch only the tables it owns.
Within that database, each test still needs a clean starting state:
| Isolation option | Speed | Fidelity | Parallel runs |
|---|---|---|---|
| Transaction rollback | Fastest | Lower: commit-time constraints, triggers and multi-transaction code never run | Safe, but code that opens its own transactions escapes the rollback |
| Per-test data (unique keys per test) | Fast | High: real commits | Safe if no test asserts on global counts |
| Truncation | Slowest as tables grow | High | Unsafe within a schema; serialize per module |
A shared physical database causes problems even when the schemas are separate. Sequences shared across modules make generated IDs nondeterministic. Global tables such as users or audit logs become hidden coupling. A single migration history forces every module's tests to wait for the full migration run. Moving to per-module schemas means splitting the migration history by owner, rewriting cross-schema foreign keys as references by ID, and giving each module its own sequences.
This setup is slower than pure unit tests. When deadlines get tight, teams are tempted to switch back to in-memory fakes, and the database-specific bugs those fakes hide then show up in production.
-- PostgreSQL: one schema, role and sequence per module in a shared test database.
-- The 'billing' module owns these objects; its tests connect as billing_test.
CREATE SCHEMA billing;
-- Module-owned sequence, so other modules' inserts cannot shift billing's IDs.
CREATE SEQUENCE billing.invoice_id_seq;
CREATE TABLE billing.invoices (
id bigint PRIMARY KEY DEFAULT nextval('billing.invoice_id_seq'),
-- Reference by ID only. A foreign key to customers.customers would couple
-- billing's migrations and test data to the customers module.
customer_id bigint NOT NULL,
external_ref text NOT NULL UNIQUE, -- per-test unique key, e.g. 'test-<uuid>'
total_cents bigint NOT NULL CHECK (total_cents >= 0)
);
-- Tie the sequence to the column so TRUNCATE ... RESTART IDENTITY resets it.
ALTER SEQUENCE billing.invoice_id_seq OWNED BY billing.invoices.id;
-- Test role that can only see and change its own schema.
CREATE ROLE billing_test LOGIN PASSWORD 'change-me-local-only';
GRANT USAGE ON SCHEMA billing TO billing_test;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA billing TO billing_test;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA billing TO billing_test;
-- Unqualified table names in billing's code resolve to its own schema.
ALTER ROLE billing_test SET search_path = billing;
-- Truncation-based reset: affects billing only. Run it between tests
-- that are serialized within this module; other modules keep running.
-- The schema owner runs this, because TRUNCATE was not granted to billing_test.
TRUNCATE billing.invoices RESTART IDENTITY;
Illustrative PostgreSQL setup that gives one module its own schema, sequence and least-privilege test role, uses ID references instead of cross-schema foreign keys, and resets only that module's tables between serialized tests.
Faking other modules, contract tests, and testing events between modules
Faking a neighbouring module is a different matter from faking the database. When the billing module calls the customers module, the test should not depend on the customers module's tables, migrations or internal state. Replace it with a fake that implements the same public interface billing already depends on. Put the fake in the customers module, next to the interface it imitates, and make the customers team its owner. The team that changes the behaviour is then the team that updates the fake.
To keep the fake honest, write one contract test suite against the interface and run it twice: once against the fake and once against the real implementation backed by real infrastructure. In the code example, an abstract test class defines the behaviour, and two small subclasses each supply an implementation. If the real module starts rejecting duplicate emails and the fake does not, the fake's run fails.
Events need tests at three levels:
- Publisher tests assert that a command produced the expected event type with the expected payload fields, read from the outbox or a captured publisher.
- Subscriber tests pass a hand-built event to the handler and check the resulting state, with no publisher involved.
- A few wired tests connect two real modules through the actual outbox and dispatcher. These catch serialization mismatches, out-of-order delivery and events published before the transaction commits.
The trade-off is real. Fakes are code that someone must maintain, and they can still drift in behaviour the contract suite does not cover. Contract tests reduce that risk but do not remove it.
# customers/testing/contract.py -- owned by the customers team, next to CustomersApi
import pytest
from customers.api import CustomerAlreadyExists, CustomerNotFound
class CustomersContract:
"""Behaviour every CustomersApi implementation must have. No Test prefix, so pytest skips it."""
@pytest.fixture
def customers(self):
raise NotImplementedError # each subclass supplies an implementation
def test_registered_customer_can_be_fetched(self, customers):
customer_id = customers.register(email="ana@example.com", name="Ana")
assert customers.get(customer_id).email == "ana@example.com"
def test_duplicate_email_is_rejected(self, customers):
# If the real module enforces this and the fake does not, the fake's run fails
customers.register(email="ana@example.com", name="Ana")
with pytest.raises(CustomerAlreadyExists):
customers.register(email="ana@example.com", name="Ana B")
def test_unknown_id_raises(self, customers):
with pytest.raises(CustomerNotFound):
customers.get("cus_does_not_exist")
# customers/tests/test_customers_contract.py -- the same suite, run twice
from customers.testing.contract import CustomersContract
from customers.testing.fake import FakeCustomers
from customers.module import build_customers_api
class TestFakeCustomers(CustomersContract):
@pytest.fixture
def customers(self):
return FakeCustomers() # in-memory fake that billing's tests depend on
class TestRealCustomers(CustomersContract):
@pytest.fixture
def customers(self, db_session): # project fixture backed by a real database
return build_customers_api(db_session)
A shared pytest contract suite in an abstract base class, run once against the customers module's in-memory fake and once against the real database-backed implementation, so behaviour such as duplicate-email rejection must match in both.
Where unit tests and end-to-end tests still belong
Module tests do not replace every other kind of test. Two kinds still earn their place, each with a narrow job.
Unit tests for logic with many inputs
Write focused unit tests for pure domain logic: pricing and tax calculations, state machines, eligibility checks, and validation rules. These pieces have many input combinations, and covering them through a module's public surface would mean dozens of slow setups to reach each branch. Test them directly, with no database, broker or container, and keep them inside the owning module next to the code they cover.
Avoid unit tests for orchestration and persistence code. A handler that loads an aggregate, calls a service and saves the result has little logic of its own. Mocking each of those calls produces a test that repeats the implementation and breaks on every refactor. The module test already covers that path against real infrastructure.
End-to-end tests as smoke checks
Keep end-to-end tests to a handful of critical journeys, such as sign-up or checkout, driven through the real HTTP or message entry points. Their job is to confirm the assembled application starts and the pieces connect. They are the wrong place to cover business rules. Those belong in module tests, where failures point to one module.
Thin end-to-end coverage can miss wiring faults: a dependency registered in a module's test host but not in the production composition root, a transaction that should span two modules but commits separately, or a module whose startup code runs before the one it depends on. Write targeted wiring tests for these. Boot the real composition root, resolve every registered handler, and assert startup order and transaction boundaries explicitly.
Enforcing module boundaries in the build and organizing the suite in CI
Fail the build on boundary violations
Write dependency-rule tests and run them with the normal suite. For example, code outside billing may reference only billing.api, and any reference from another module into billing.internal fails the build. These checks are cheap, and they catch drift when the change is made instead of months later when someone tries to extract the module.
Import rules alone give false confidence because they don't see the database. A module that queries billing_invoices through raw SQL, or loads another module's entities through a shared ORM context, couples two schemas without a single import. Check for this as well:
- Give each module its own schema and a database role that can access only that schema. Cross-schema reads then fail during module tests.
- Scan SQL strings and ORM mappings for table names owned by other modules.
Repository layout and CI stages
Keep tests next to the module they cover, such as modules/billing/src and modules/billing/tests, so each module's suite can run on its own. Typical commands are ./gradlew :billing:test or pytest modules/billing.
Split CI stages by test type:
- Boundary checks and unit tests.
- Module tests, run in parallel with one job per module.
- The small end-to-end set.
Cheap failures stop the pipeline before the expensive stages start.
Running only the tests for changed modules is an optional later step. Changes to the shared kernel, shared libraries, event schemas or migrations can break modules nobody touched, so treat changes in those paths as a trigger to run everything. Always run the full suite on main and before every release.
Alternatives, when to avoid this approach, and impact on cost and engineering time
How it compares
| Approach | Primary test unit | Main weakness for a modular monolith |
|---|---|---|
| Unit-heavy pyramid | Class or function | Tests couple to internals, so refactors inside a module break them |
| Testing trophy | Integration slice | Slices often cross module lines, blurring ownership |
| End-to-end-heavy | Whole application | Slow, flaky feedback that is hard to attribute to one module |
| Modules as microservices | Module plus consumer-driven contracts | Contract tooling and versioning for calls that never leave the process |
| Module-centric | Module public surface | Pins interfaces, so boundaries become harder to move |
When to avoid it
Hold off while module boundaries are still unstable or wrong. Module tests encode each module's public surface, and every test written against a misplaced boundary makes that boundary harder to move. When you redraw boundaries, expect to rewrite the affected module tests. Do not keep a bad split alive to save the suite.
Cost and time
You pay for container infrastructure in local and CI environments, for fakes and contract tests that must track real module behaviour, and for per-module schema migrations that need upkeep.
You get back fewer tests broken by internal refactors, because tests only touch the public surface. A failing test points to one owning team. Local feedback is faster because you run one module's suite, not the whole application.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to modular monolith testing, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Changing a route's URL or method automatically notifies the services that consume it — no hunting through repos for callers.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- Consumers learn about a breaking change at review time rather than from production errors.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.