Chapter 7 — Mocking and Stubbing Without the Framework Getting in the Way
Mocks exist for one reason: the system you'd have to summon to test the unit under test is too expensive, too slow, or too unavailable. Once you've made the choice to mock, the framework should help — not impose its own opinions about what mocks should look like. This chapter is about how testbase's mocking helpers stay out of the way.
codecraftai-testbase ships its own mocking module rather than
wrapping unittest.mock. The choice is deliberate: the
portfolio's mocking needs don't fit neatly under the
unittest.mock shape, and rather than fight it, testbase ships
primitives shaped for the actual cases.
This chapter walks the four primitives that make up the
mocking surface — MockType, SpecViolationError,
SideEffect, and the Mock class itself — and discusses when
to mock at all. The chapter intentionally does not try to
re-derive the entire unittest.mock API; it walks the parts
testbase added because unittest.mock alone wasn't enough, and
the parts testbase made simpler because unittest.mock did
more.
When to mock at all
Before talking about the API, the choice. The portfolio has three rules of thumb that drive whether a test mocks:
- Mock at the boundary, not in the middle. If the test verifies behaviour A → B → C, and only C touches the external system, mock only C. Mocking B is a sign the test is testing too much.
- Don't mock what you don't own. Mocking the standard
library, the
requestsHTTP client, ordatetime.nowis usually a code smell. Wrap the dependency in a class you own and mock the class. - An integration test that mocks the integration is not doing its job. If the test is for the integration with service X, the test must talk to a service-X-shaped thing — at minimum a fake-X-server, ideally service X itself. A mock of X tests nothing X-specific.
These rules come from Volume 0 chapter 1's debuggability principle: a mock that lies hides bugs from the test. The framework's job is to make the legitimate mocks easy and the illegitimate mocks at least uncomfortable. Spec violation errors (below) are how testbase makes the second part work.
MockType: knowing what kind of mock you have
# codecraftai-testbase/source/packages/ccai/testbase/mocking.py:46–56
class MockType(IntEnum):
Generic = 0
Class = 1
Instance = 2
Property = 3
Function = 4
Method = 5
Every testbase Mock carries a MockType. The type is what
the mock represents:
Generic— a free-floating mock with no spec. Use sparingly.Class— a mock that stands in for a class (calls to it return mocks shaped like instances).Instance— a mock that stands in for an instance of a class.Property— a mock that stands in for a property (@property/ descriptor).Function— a mock that stands in for a free function.Method— a mock that stands in for a method (the first argument isself).
The type matters because testbase's spec validation behaves
differently for each kind. A Class mock allows attribute
access for any class member; an Instance mock allows
attribute access only for instance members; a Method mock
expects to be called with self as the first argument.
A consumer who picks the wrong type gets a
SpecViolationError (below) at the first wrong access — not
a mysterious AttributeError later when the mock returns
something it shouldn't have.
SpecViolationError: refusing to play along
# codecraftai-testbase/source/packages/ccai/testbase/mocking.py:25–43
class SpecViolationError(BaseException):
"""
Raised when a mock is used in a way that violates its spec.
A spec violation is a programming error in the test, not a
runtime condition the test can recover from. The error
inherits from BaseException so that a `try / except Exception`
in the code under test cannot accidentally swallow it.
"""
pass
SpecViolationError inherits from BaseException — the same
discipline as SemanticError and ConfigurationError from
Volume 0 chapter 3. An ordinary try / except Exception does
not catch it. This matters because a mock is, by definition,
inserted into code that is being tested; the code may have
broad exception handlers that would swallow a normal exception
and let the test pass spuriously. By inheriting from
BaseException, a spec violation forces the test to fail
loudly.
A spec violation happens when:
- A
MockType.Instancemock is accessed for a member the spec class does not declare. - A
MockType.Methodmock is called withoutself. - A
MockType.Functionmock is called with the wrong number of arguments and signature checking is enabled. - A
MockType.Classmock is used as if it were an instance (or vice versa).
The principle: if the test is wrong about what shape of thing it is mocking, the framework refuses to play along.
SideEffect: the simplest mock behaviour
For mocks whose only job is to return a value or run a
callable, testbase ships SideEffect:
# codecraftai-testbase/source/packages/ccai/testbase/mocking.py:59–120
class SideEffect:
def __init__(self,
callable: Callable,
default_args: Optional[Union[List[Any], Tuple[Any, ...]]] = None,
default_kwargs: Optional[Dict[str, Any]] = None) -> None:
self._callable = callable
self._default_args = default_args if default_args is not None else ()
self._default_kwargs = default_kwargs if default_kwargs is not None else {}
return
def __call__(self, *args, **kwargs) -> Any:
merged_args = self._default_args + tuple(args)
merged_kwargs = {**self._default_kwargs, **kwargs}
rtnval = self._callable(*merged_args, **merged_kwargs)
return rtnval
Three things to notice:
- The default args and kwargs are stored as instance
variables in
__init__— Volume 0 chapter 2's discipline on instance variables. - The mutable defaults trap (Volume 0 chapter 2) is avoided
by using
Noneas the default and assigning the empty tuple / dict inside. - The merge order matters: caller arguments override the
defaults. A test sets up a
SideEffect(lambda x: x*2, default_args=(5,))and the calleffect()returns 10;effect(7)returns 14.
SideEffect is the building block for "this mock returns X"
or "this mock runs Y when called." Larger mock behaviours
compose SideEffect instances.
Mock and MockContext
The two top-level mocking constructs:
Mock(codecraftai-testbase/source/packages/ccai/testbase/mocking.py:1603) — the spec-aware mock class itself. Constructed with a spec (a class, an instance, or a function), it produces attribute access and call behaviour shaped by the spec.MockContext(line 304) — a context manager that sets up a group of mocks, optionally patches names in a module, and tears the patches down on exit.
A representative usage:
import testbase
from testbase.mocking import Mock, MockContext, MockType
from ccai.billingkit.invoice import InvoiceService
@testbase.attach.parameter(some_db_session, identifier="db")
def test_invoice_service_uses_billing_runtime(db):
"""
Verify InvoiceService consults the billing runtime.
"""
runtime = Mock(spec=BillingRuntime, mock_type=MockType.Instance)
runtime.tax_rate = SideEffect(lambda: Decimal("0.0875"))
with MockContext() as ctx:
ctx.patch("ccai.billingkit.invoice.get_runtime",
Mock(spec_class=BillingRuntime,
mock_type=MockType.Function,
side_effect=SideEffect(lambda: runtime)))
service = InvoiceService(db=db)
invoice = service.create_invoice(...)
rtnval = invoice.tax_amount > Decimal("0")
assert rtnval
return
The Mock(spec=BillingRuntime, ...) constructor produces a
mock whose attribute access is restricted to members of
BillingRuntime. Setting runtime.tax_rate = SideEffect(...)
binds a callable behaviour to the attribute. The
MockContext patches get_runtime in the module under test
for the duration of the with block; on exit, the patch is
unwound automatically.
The pattern reads similarly to unittest.mock.patch but with
testbase-specific specifics: spec validation is on by default,
spec-violation errors bubble unstoppably, and the MockType
disambiguates what kind of thing each mock is.
What testbase mocking does not do
A short list of unittest.mock conveniences testbase deliberately omits:
MagicMock's automatic dunder support. Testbase'sMockdoes not auto-magic__len__,__iter__,__getitem__, etc. If you need a mock that supports iteration, you set up the dunder explicitly with aSideEffect. The reasoning: an auto-magic iter that silently returns an empty iterator hides bugs more often than it helps.- String-based attribute lookup at construction time.
unittest.mock lets you write
mock.foo.bar.baz()and silently invents the chain. Testbase's spec-awareMockrefuses unknown attributes; the chain has to be set up with explicit attributes that have explicit specs. - Implicit return values. A testbase
MockofMockType.FunctionraisesSpecViolationErrorif called before aSideEffectis bound. The framework refuses to invent a return value the test author did not declare.
These are not small differences. A test author migrating from unittest.mock will hit each of them. The framework's philosophy from Volume 0 chapter 1 — that explicit beats implicit, even at the cost of more typing — drives every one.
Mocking and the resource factory model
Mocks fit into the resource factory model from Chapter 3 in two ways:
Mock-yielding originators
A factory can yield a mock instead of a real resource:
@testbase.factories.resource_factory(scope=ResourceLifespan.Test)
def billing_runtime_mock():
"""
Yields a Test-scoped mock of BillingRuntime for unit tests
that don't need the real runtime.
"""
runtime = Mock(spec=BillingRuntime, mock_type=MockType.Instance)
runtime.tax_rate = SideEffect(lambda: Decimal("0.0875"))
yield runtime
return
A test attaches the mock-yielding factory exactly as it would attach a real one:
@testbase.attach.parameter(billing_runtime_mock, identifier="runtime")
def test_invoice_total(runtime):
...
The advantage: the test does not know it has a mock. Swapping
to a real BillingRuntime later means changing the factory,
not the test.
MockContext for short-lived patches
For patches that should not outlive a single test,
MockContext is the right shape. The factory model is for
resources whose lifespan matches one of the standard
(Session / Package / Test) scopes; the MockContext is for
patches that need to be torn down at a finer scope —
mid-test, around a specific code path.
The two compose: a Test-scoped factory provides the standing
mock; a MockContext inside the test patches one specific
name for one specific code path.
Mocks in JSOS records
Mocks do not produce special JSOS records. A test that runs against a mock produces the same JSOS shape (Chapter 5) as a test that runs against a real resource. This is intentional: the agents CLI (Chapter 6) treats mock-running tests identically to real-resource tests. The fact that a test was mocked is information for the test author and the test's maintainer; the framework's record of the run does not need to know.
If a build engineer wants to track which tests use mocks, the
convention is to attach a moniker — @testbase.moniker("unit")
on tests that mock everything, @testbase.moniker("integration")
on tests that talk to real resources. The JSOS record
carries the monikers (Chapter 5), and the agents CLI can
filter on them.
A worked anti-example
A mock that hides a bug:
# Bad: this test passes because the mock pretends success.
def test_invoice_creation_emits_audit_log():
audit_log = Mock(spec=AuditLog, mock_type=MockType.Instance)
audit_log.emit = SideEffect(lambda *args, **kwargs: None)
service = InvoiceService(audit_log=audit_log)
service.create_invoice(...)
rtnval = True # The test asserts nothing useful.
assert rtnval
return
The test "passes" but it does not verify the property the
test name claims. The audit log was mocked; the call to
emit was made; the test does not check that the call
happened. A reviewer should reject this test.
The fix:
def test_invoice_creation_emits_audit_log():
"""
Verify that creating an invoice records an entry on the
audit log.
"""
audit_log = Mock(spec=AuditLog, mock_type=MockType.Instance)
audit_log.emit = SideEffect(lambda *args, **kwargs: None)
service = InvoiceService(audit_log=audit_log)
service.create_invoice(...)
rtnval = audit_log.emit.call_count > 0
assert rtnval
return
Now the test verifies the property the name claims. The
call_count attribute is one of the spec-aware behaviours
testbase mocks expose for verification — the framework's
counterpart to unittest.mock's call-tracking facilities.
What's next
- Chapter 8 —
arcli. The CLI toolkit underneath every testbase subcommand, including theagentsgroup. - Chapter 9 — Single-host landscape concepts. How to write a test that uses a real resource selectively, with the right hooks for Volume 2's distributed runner to extend.