Skip to main content

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.java declares class AuthControllerTest in package 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 *ITTenantIsolationIT, 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 @Query in repository/ 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.
  • @PreAuthorize is 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

AreaClassesTestsWhat it covers
service/impl/24237The payroll engine and the money math. PayrollServiceImplTest alone is 70 tests
controller/858Standalone MockMvc; EmployeeControllerTest (23 tests) is the reference
security/640ALTCHA, rate limiting, login lockout, forced password change, tenant status on login
service/provisioning/323Self-serve signup, verification codes, trial expiry
tenancy/318Tenant coverage and the scoped executor (plus the four *IT classes above)
service/billing/provider/218Polar webhook signature verification and event mapping
mail/215The {{token}} template renderer and the SMTP service
config/, util/, model/314Argon2id 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:

TargetWhy it matters
EmployeeServiceImpl450 lines, the most-used service in the app, zero unit tests
TimesheetServiceImpl269 lines of state machine feeding attendance-derived pay
EwtServiceImplBIR expanded withholding — tax math with no coverage
EmployeeDeductionServiceImplValidation rules that feed the money math directly
LeaveBalanceServiceImplAccrual arithmetic — exactly the shape unit tests are good at
PayslipServiceImplShipped 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:

  1. Name the class <ClassUnderTest>Test and put it in the package mirroring the subject. When one subject needs several files, suffix the concern — PayrollServiceImplGrossIncomeTest, PayrollServiceImpl2026ComplianceTest.
  2. 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.
  3. Mock every collaborator with @Mock and build the subject with @InjectMocks under @ExtendWith(MockitoExtension.class).
  4. Assert with AssertJ. assertThat(...) is used at 873 call sites and there is not one JUnit assertEquals/assertTrue/assertThrows in the suite. Keep it that way.
  5. Compare money with isEqualByComparingTo, never isEqualTo — 255 call sites depend on it. BigDecimal.equals compares scale, so 1000 and 1000.0000 are unequal and your test fails for no real reason. Pass the expected value as a string.
  6. 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.