Testing Strategy

1. Scope and vocabulary

A test verifies the behavior of a System Under Test (SUT), which can be a single function or the whole application. Every test has four phases: setup (build the fixture), execute (call the SUT), verify (assert), teardown (clean up).

When the SUT has dependencies, they are replaced by test doubles. A stub returns canned values to the SUT; a mock also records how it was called so the test can assert the interaction. PackyTrace uses stubs almost exclusively: a small hand-written struct or object that satisfies a port and returns fixed data. There is no mocking framework in the repository, in Go or in TypeScript.

2. The test pyramid

Level What it verifies In PackyTrace Size
Unit business logic without deployed infrastructure core entities, use cases, router/use-case combinations and HTTP clients against controlled servers broad, fast default suite
Integration a service really talks to its infrastructure Postgres repository and event-store rebuild tests, opt-in through TEST_DATABASE_URL opt-in
Component one service through its adapter boundary partially covered by router tests; BDD examples are not automated as a suite incomplete
End-to-end the whole application make journey, one user-journey script one primary journey

The shape is the one the pyramid asks for: a wide, fast base and a single slow test at the top. The component tier is thin for the wrong reason, though: it is specified but not yet automated (see Section 6).

Tooling uses Go's standard testing with table-driven cases and the race detector, Vitest for TypeScript and Svelte. Go tests live in external test packages (package passport_test), so they can only reach the package's public API.

Typical examples of what the base contains: Fridge and Shopping List replay tests folding stored events back into aggregate state, Personalization rule sets and health-verdict computation, Passport use cases proving stale-cache and section-level degradation against stubbed ports, API Gateway router tests checking identity-header hygiene over httptest, and Kafka decoding tests driven by controlled messages.

Run the complete self-contained test layer with make test; Go packages always run with the race detector.

3. Unit tests

A solitary unit test isolates the SUT and doubles all its dependencies. A sociable unit test lets the SUT use its production collaborators. PackyTrace does not choose per test: the position of the code in the hexagon decides.

Code with no outbound dependencies is tested sociably, because there is nothing to double. That covers core entities and value objects such as FreshnessOf or the verdict ruleset: call the function, assert the result.

Code that owns driven ports is tested solitarily. A use case like ResolveScan receives stubs (fakeCatalog, fakeScans) in place of every port, so the test controls what the outside world returns. Outbound adapters are solitary for the same reason from the other side: the Open Food Facts, Edamam and brand-registry clients run against an httptest server returning recorded payloads instead of the remote provider.

Outbound repositories are the exception and are covered at the integration tier against an actual Postgres instance, because a fake repository would hide the SQL being tested.

Inbound HTTP handlers use a sociable approach. In the Go services there is no driving-port interface to mock: the handler calls the use case's Execute directly. A router test therefore constructs the production use case with stubbed ports and drives both together over httptest. It is slightly less isolated, and in exchange each test also proves the handler is wired to the right use case.

4. Integration tests

fridge_repo_test.go and shopping_list_repo_test.go are the only tests that need an actual infrastructure, and they follow the four phases closely:

  • setup: create the schema in a throwaway Postgres given by TEST_DATABASE_URL
  • execute: append domain events through the repository
  • verify: delete the projection and prove it can be rebuilt from the event log alone
  • teardown: drop the test data

They skip when TEST_DATABASE_URL is unset, which keeps make test hermetic and fast. The default make check therefore does not prove behavior against Postgres. Run the integration suite whenever repository SQL or rebuild logic changes, and add an explicit CI job if it is intended to be a required gate.

5. Contract tests

One way to keep independently evolving services in agreement is consumer-driven contract testing, where consumer expectations run against the provider. PackyTrace does not currently use that approach. It has structural contract checks instead:

  • Asynchronous (publish/subscribe): event payloads are versioned JSON Schemas in contracts/. make contracts-check validates them and regenerates the Go and TypeScript types, failing CI on generated-code drift. This reduces structural disagreement when both sides use the generated type; it does not prove runtime or semantic compatibility.
  • Synchronous (REST request/response): each service declares its surface in 'services//openapi.yaml', linted with Redocly.

The current single-team workflow uses these structural checks instead of consumer-driven contract tests. The limitation remains: schemas prove the shape of an interaction, not the behavior either side expects. Nothing except the journey test checks that the gateway's expectations of a service still hold.

6. End-to-end tests

make journey runs scripts/journey-test.sh, which is a user-journey test. It builds and starts the whole Compose stack, waits for container health, then walks one path through the system: scan a known GTIN, follow the GS1 resolver redirect, keep the visitor cookie, report an unknown product, and read /metrics and /ready. It always tears the stack down, printing container state and logs first when an assertion fails.

One journey keeps the slow tier small while still catching failures that no isolated test can see, such as a bad image, a missing migration or a broken gateway route.

7. Tests in the deployment pipeline

The same tests run again in the pipeline, ordered so that the fastest feedback comes first:

  1. Before the commit, make check runs locally: lint, boundary checks, unit tests, type checks, contract and sqlc drift, strict docs build, Compose validation and Kubernetes rendering. This is the gate that must pass before a PR.
  2. On relevant pull requests and pushes, ci.yml repeats make lint test typecheck and builds the frontends according to its path filters.
  3. On pull requests and configured branch events, docs-contracts.yml validates the contracts and renders the Compose and Kubernetes manifests, catching deployment descriptors that no unit test sees.
  4. On the built images, journey.yml runs the end-to-end journey from Section 7.
  5. In a running environment, Prometheus recording rules evaluate the two SLOs continuously against live traffic; this is the only tier that observes deployment behavior.

The structural rules that protect the layering run in the same pipeline and are catalogued separately under fitness functions.