“The tests pass on my machine” is often treated as a testing problem. Sometimes it is really a dependency-graph problem.
Consider a browser application that contains a small deterministic rule: given one origin, derive another related origin. The rule has no UI state, network access, storage, or framework lifecycle. It deserves focused unit tests. The quickest route appears to be adding a project reference from the test project to the browser application and calling the rule directly.
A plain local test run can make that choice look correct. Compilation succeeds, the assertions pass, and the change feels complete. Then CI evaluates the same test project with different SDK properties and fails before test discovery. The failure is not in the rule. It comes from importing the application head and all the build behavior attached to it.
A project reference imports more than types
We often read a project reference as “this assembly may use those classes.” In SDK-style .NET builds, it also joins build graphs, targets, workloads, generated assets, and property-sensitive behavior.
An executable browser project is not just another class library. It may bring web-asset processing and browser-specific targets. A test project that references it now participates in enough of that build pipeline for CI properties to matter.
Imagine that CI disables web-asset work while compiling test projects. That may be a sensible estate-wide convention for ordinary tests. Once a test imports a browser head, however, the browser SDK can expect targets or tasks that the reduced test build does not provide. Evaluation fails before the runner reaches a single assertion.
The local green run and the CI failure are therefore compatible facts. They exercised different build contracts.
Do not make the pipeline absorb a bad edge
The immediate reaction might be to special-case the workflow: enable the missing feature for this test project, remove the shared flags, or create a new CI branch for the exception.
That can make the build green while preserving the architectural problem. A test project still depends on an executable application head only to reach a pure rule. Every future change to that head can now expand the test project’s build surface.
A better question is: what is the smallest dependency that the test actually needs?
In this case, it needs the deterministic mapping rule, not the application host. Moving that rule behind an existing neutral library boundary lets both the browser application and the test project depend inward. The invalid application reference disappears, and the graph gains no replacement edge because both consumers already reference the neutral library.
That is a small refactor with a useful architectural effect: the test compiles against the capability it exercises rather than the executable that happened to contain it.
The neutral seam is a trade-off, not a slogan
“Move it to shared” can become a dumping-ground strategy. A neutral library should not accumulate unrelated helpers simply because tests can reach it.
The placement here is deliberately narrower. The rule sits beside the abstraction used by its only application consumer, in a library already shared by the two relevant projects. Its dependencies remain simple, and the compromise is documented.
The documentation matters. It should explain why the apparently more natural location is currently invalid, why the selected seam is acceptable, and what future condition should trigger another move. For example, if the application head later gains its own compatible test project, the rule may be able to return to the feature boundary without weakening test coverage.
This is not architectural purity. It is an explicit, reversible decision.
Reproduce the build contract that failed
After changing the graph, rerun more than the convenient local command. Use the properties that exposed the failure.
A focused verification sequence is:
- Confirm the test project no longer references the executable head.
- Confirm both consumers already reference the neutral library.
- Run the focused behavior tests.
- Build or test with the same SDK properties CI supplies.
- Check that no unrelated project edge or asset requirement appeared.
The point is not to mimic every pipeline detail locally. It is to reproduce the part of the build contract relevant to the failure. A bare test run proves behavior under default properties; it does not prove compatibility with a property-sensitive CI graph.
Review the graph before reviewing the assertion
When a test needs code from an executable head, pause before adding the reference. Ask:
- Is the code actually coupled to the host, or is it a pure rule trapped there?
- Will this reference import browser, web, desktop, or mobile SDK targets?
- Do local and CI builds use the same relevant properties?
- Is there an existing inward-facing seam both projects already depend on?
- If the new location is a compromise, is its exit condition recorded?
This lesson applies beyond browser applications. Desktop heads, mobile apps, migration executables, worker hosts, and deployment projects can all carry build behavior that ordinary libraries do not.
The durable fix is often not another pipeline exception. It is a smaller, more truthful dependency: put the deterministic rule where its consumers can reach it without importing an executable world they do not need.
Top comments (0)