Backend tests (JUnit + Mockito)
The unit suite for the Spring Boot service. 51 test classes, 423 tests, all
green in about 50 seconds including a cold compile of the 919 main sources.
Counts measured on 2026-08-04 with mvn test; they drift, so regenerate rather
than trust them (the last line of mvn test is authoritative).
The colocated runbook with the copyable templates and the full conventions is
backend/src/test/README.md. This page is
the orientation: how to run it, what it does and does not cover, and where the
suite's two different styles of test apply.
Run it from backend/, not the repo root
cd backend
mvn test
mvn test at the repo root runs zero tests and still prints BUILD SUCCESS.
The root pom.xml is a different, dead project — the legacy
JavaFX desktop app (../archive/legacy-javafx-readme.md)
— with no <modules> and no src/, and it does not reference backend/ at
all. This costs someone an afternoon roughly once per new contributor. Either
cd backend first, or run mvn -f backend/pom.xml test from anywhere.
The second trap in the same family: ./mvnw does not exist in backend/.
The wrapper is at the repo root only. From backend/ use plain mvn; from the
root use ./mvnw -f backend/pom.xml test.
Failures print to the console and land in backend/target/surefire-reports/ —
one .txt and one .xml per class. The .txt keeps the full stack trace after
the console has scrolled away, and it is what CI uploads as an artifact when the
job fails.
Docker is required, for two classes
This changed, and older notes in the repo still say otherwise. Most of the suite
runs offline with nothing else installed, but TenantCoverageTest and
TenantHarnessTest extend
TenantTestSupport,
which starts a real postgres:16-alpine through Testcontainers from a static
initialiser. Without a working Docker daemon, mvn test fails — and it fails
badly, as ExceptionInInitializerError in TenantTestSupport with the classes
after it reporting NoClassDefFoundError, none of which mentions Docker. If you
see that cascade, check docker info before you go looking for a code bug.
To run everything else while Docker is unavailable, exclude them:
mvn test -Dtest='!Tenant*Test'
Filtering a run
Surefire's -Dtest accepts classes, patterns and exclusions:
mvn test -Dtest=PayrollServiceImplTest # one class
mvn test -Dtest=PayPeriodPlannerTest,BonusServiceImplTest # several
mvn test -Dtest='PayrollServiceImplTest#create*' # methods by pattern
mvn test -Dtest='com.motorph.payroll.security.*Test' # a package
mvn test -Dtest='!WarehouseLocationHierarchyTest' # all but one
mvn clean test # after changing an entity or migration
Two things worth knowing about -Dtest:
- It matches the class name, not the filename. That matters for one file:
controller/auth/AuthControllerLoginTest.javadeclaresclass AuthControllerTestinpackage com.motorph.payroll.controller. Filename, directory and class name all disagree, and the one Surefire obeys is the class — so it is-Dtest=AuthControllerTest. - It overrides the default include patterns, which is the only way to run the integration tests described below.
The four integration tests that never run by default
Four classes in
tenancy/ are named
*IT — TenantIsolationIT, RowLevelSecurityIT, TenantProvisioningIT and
RecognitionTenantIsolationIT, 35 tests between them. Surefire's default
includes are **/Test*.java, **/*Test.java, **/*Tests.java and
**/*TestCase.java, so *IT.java matches none of them, and no
maven-failsafe-plugin is bound to the build (it appears only in the Spring Boot
parent's pluginManagement). The consequence is worth stating plainly:
mvn test does not run them, mvn verify does not run them, and CI does not
run them. They are green — verified by running them explicitly — but nothing
runs them automatically, so a change that breaks tenant isolation ships green.
Run them by name:
mvn test -Dtest=TenantIsolationIT # 6 tests, ~17s
mvn test -Dtest='*IT' # all four
Do that before merging anything that touches tenancy, entities or migrations.
What the suite is: no Spring context, everything mocked
With the tenancy classes as the sole exception, no test starts Spring. There
is no @SpringBootTest and no @WebMvcTest anywhere. Every test is a plain
JUnit 5 class that news up its subject and hands it Mockito mocks —
35 of the 51 classes carry @ExtendWith(MockitoExtension.class). That buys speed
and offline operation, and it costs two specific kinds of confidence:
- Repository behaviour is never tested. Every
@Queryinrepository/is stubbed, so a broken JPQL string compiles, passes, and fails at runtime. Query changes need a manual check against the running stack or an e2e spec. @PreAuthorizeis never tested. Controller tests use standalone MockMvc, which applies no filters and no method security. Permission enforcement is covered, where it is covered at all, by the Playwright suite (e2e-playwright.md).
Know both before you tell anyone the backend is "tested".
The tenancy tests exist precisely because mocks cannot see the thing they check.
Tenant isolation is a property of the SQL Hibernate emits, and a repository that
has forgotten its tenant predicate returns exactly what the mock was told to
return — so those tests use @DataJpaTest against a container running the real
Flyway migrations.
Where the tests are
| Area | Classes | Tests | What it covers |
|---|---|---|---|
service/impl/ | 24 | 237 | The payroll engine and the money math. PayrollServiceImplTest alone is 70 tests |
controller/ | 8 | 58 | Standalone MockMvc; EmployeeControllerTest (23 tests) is the reference |
security/ | 6 | 40 | ALTCHA, rate limiting, login lockout, forced password change, tenant status on login |
service/provisioning/ | 3 | 23 | Self-serve signup, verification codes, trial expiry |
tenancy/ | 3 | 18 | Tenant coverage and the scoped executor (plus the four *IT classes above) |
service/billing/provider/ | 2 | 18 | Polar webhook signature verification and event mapping |
mail/ | 2 | 15 | The {{token}} template renderer and the SMTP service |
config/, util/, model/ | 3 | 14 | Argon2id encoder, PH holiday defaults, tenant active flag |
The weight is where it should be. The heaviest-tested code is the money math — the payroll engine, the withholding-tax calculator, the three contribution-rate services and the per-day pay calculator — because that is the code whose bugs cost people real pesos and produce filings that have to be amended. The rules those tests enforce are documented in ../business-rules.md.
Where it is thin
14 of the 85 *ServiceImpl classes have a test. CRM, recruitment,
inventory, portal, messaging and most of HR have none. Ranked by risk, these are
the gaps worth filling first — every one of them is untested today:
| Target | Why it matters |
|---|---|
EmployeeServiceImpl | 450 lines, the most-used service in the app, zero unit tests |
TimesheetServiceImpl | 269 lines of state machine feeding attendance-derived pay |
EwtServiceImpl | BIR expanded withholding — tax math with no coverage |
EmployeeDeductionServiceImpl | Validation rules that feed the money math directly |
LeaveBalanceServiceImpl | Accrual arithmetic — exactly the shape unit tests are good at |
PayslipServiceImpl | Shipped uncompilable once already; nothing would have caught it |
Pick a service whose logic is arithmetic or validation. Skip thin CRUD passthroughs — mocking a repository to prove you called the repository tests nothing.
Dead files in the tree, ignore them
Six files contribute no tests.
controller/auth/
holds five three-line class stubs with no package declaration and no
methods — AuthControllerMeTest, JwtTokenManagerTest,
JwtAuthenticationFilterTest, JwtAuthenticationEntryPointTest and
LoginAuthenticatesUserTests — which compile into the default package and run
nothing. A seventh path,
payroll_engine/gross_pay/AttendanceTests/TimesheetClockInAndClockOutTests.java,
is a zero-byte file. Don't extend them; delete one if you write the real thing.
Adding a test
Full templates for both styles are in
backend/src/test/README.md §4. The short
version:
- Name the class
<ClassUnderTest>Testand put it in the package mirroring the subject. When one subject needs several files, suffix the concern —PayrollServiceImplGrossIncomeTest,PayrollServiceImpl2026ComplianceTest. - Name the method
methodUnderTest_condition_expectedOutcome, camelCase either side of the underscores, so a failure names the broken rule without anyone opening the file:updateSettings_weeklyWithoutAnchor_isRejected,delete_rejectedWhenItStrandsSalariesAboveRemainingCeiling. - Mock every collaborator with
@Mockand build the subject with@InjectMocksunder@ExtendWith(MockitoExtension.class). - Assert with AssertJ.
assertThat(...)is used at 873 call sites and there is not one JUnitassertEquals/assertTrue/assertThrowsin the suite. Keep it that way. - Compare money with
isEqualByComparingTo, neverisEqualTo— 255 call sites depend on it.BigDecimal.equalscompares scale, so1000and1000.0000are unequal and your test fails for no real reason. Pass the expected value as a string. - Pin your dates. Never
LocalDate.now()in a test; functions that need "today" take it as a parameter for exactly this reason.
House style, so a reviewer is not surprised: flat @Test methods, no @Nested
anywhere, and @ParameterizedTest used exactly once
(CertificateGeneratorTest). Fixtures are private helpers per file — there is no
shared fixture module, and your first test should not build one.
The gotcha that catches everyone: Mockito is strict, so a when(...) that
never fires fails the test even when the assertions pass. That usually means the
code path short-circuits before reaching the collaborator, which is worth
knowing. Delete the stub rather than papering over it with
@MockitoSettings(strictness = Strictness.LENIENT) — only two files use that,
and both are documented compromises.
For anything that needs a real database, real permissions or a browser, write a Playwright spec instead (e2e-playwright.md).
Coverage measurement
There is no coverage tool configured — no JaCoCo, and no Surefire
configuration at all in backend/pom.xml, so
everything above is stock Surefire 3.5.6 behaviour rather than repo policy. For
a number, run the agent ad hoc:
mvn org.jacoco:jacoco-maven-plugin:prepare-agent test org.jacoco:jacoco-maven-plugin:report
# → target/site/jacoco/index.html
What CI runs
The Backend (JUnit) job in
tests.yml runs mvn -f backend/pom.xml test -B on every pull request to main/master, and deploy.yml calls the
same workflow as its first job — so a red suite stops a deploy before an image
is built. The workflow pre-pulls postgres:16-alpine with retries before the
test step, because the Testcontainers startup timeout would otherwise swallow a
slow or rate-limited pull and report it as a code failure. Keep that tag in step
with TenantTestSupport.POSTGRES if it ever changes.
What CI does not run is the four *IT classes, for the reason in the section
above. Full gate-by-gate picture: ci-quality-gates.md.