Why Linting Belongs in Your Testing Strategy
Most teams think of linting and testing as two separate activities. Linting is the cheap, fast check that runs on every commit and flags style problems, suspicious code, and risky patterns. Testing is the deeper check that proves behavior. In practice, the two are closely related. A test suite full of Thread.sleep() calls, swallowed exceptions, or assertions that can never fail is a test suite that gives you false confidence. Linting catches those problems before they turn into flaky builds and missed regressions.
If you build Java services, you probably already run a compiler, JUnit, and maybe SonarQube somewhere in the pipeline. The question is not whether to lint, but where in the testing lifecycle each check belongs, how strict it should be, and how to introduce it without freezing a team that already has a large codebase.
This article walks through the steps I use to add lint to a Java testing pipeline, from choosing tools to handling legacy code and enforcing the rules in CI.
What Linting Catches, and What It Does Not
Linting tools analyze source code without running it. Depending on the tool, they check for:
- Formatting and style: indentation, naming, import order, line length, Javadoc presence.
-
Likely bugs: null dereferences, unused variables, unreachable code, incorrect
equalsandhashCodeimplementations, resource leaks. -
Risky patterns: catching
Exceptionand ignoring it, hard-coded credentials, calls to deprecated APIs. - Architectural rules: packages that must not depend on each other, layers that must not call across boundaries.
Linting does not prove that your code is correct. It cannot tell you that a discount calculation is wrong or that a query returns the wrong customers. Its value is in removing entire categories of problems so that your tests and reviewers can focus on behavior. Treat lint as the first gate, not as a replacement for tests.
Step 1: Choose a Small Set of Tools with Clear Purposes
A common mistake is adopting five linters on the same day. The result is hundreds of violations, a red build, and a team that starts ignoring the reports. Start with a small set where each tool has a distinct job:
- Checkstyle enforces style and formatting rules. It is predictable and easy to configure.
- PMD finds code smells and common mistakes, such as empty catch blocks and unused private methods.
- SpotBugs analyzes bytecode for likely bugs, such as null dereferences and suspicious comparisons. It is most useful after the code compiles.
- ArchUnit is a library, not a standalone linter. It lets you write architectural rules as JUnit tests, for example "the domain package must not depend on the web package."
- Error Prone runs during compilation and catches a set of high-confidence mistakes, which makes it a good fit for teams that want fast feedback in the IDE.
For most teams, Checkstyle plus SpotBugs plus ArchUnit covers the important ground. Add PMD if your team wants more code-smell detection. Add Error Prone if you want compile-time checks.
Step 2: Configure Rules That Match Your Team
Default rule sets are a starting point, not a finished policy. Enable the rules your team agrees are worth enforcing, and disable the ones that produce noise. Keep the configuration in the repository, under version control, so changes go through code review.
Here is a Maven configuration that runs Checkstyle and SpotBugs during the verify phase, which is where most teams run integration tests and static analysis:
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-checkstyle-plugin</artifactId>
<version>3.3.1</version>
<configuration>
<configLocation>config/checkstyle/checkstyle.xml</configLocation>
<consoleOutput>true</consoleOutput>
<failOnViolation>true</failOnViolation>
<violationSeverity>error</violationSeverity>
<includeTestSourceDirectory>true</includeTestSourceDirectory>
</configuration>
<executions>
<execution>
<id>checkstyle-check</id>
<phase>verify</phase>
<goals>
<goal>check</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>com.github.spotbugs</groupId>
<artifactId>spotbugs-maven-plugin</artifactId>
<version>4.8.6.0</version>
<configuration>
<effort>Max</effort>
<threshold>Medium</threshold>
<failOnError>true</failOnError>
<excludeFilterFile>config/spotbugs/exclude.xml</excludeFilterFile>
</configuration>
<executions>
<execution>
<id>spotbugs-check</id>
<phase>verify</phase>
<goals>
<goal>check</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
Two details matter here. First, includeTestSourceDirectory is set to true for Checkstyle, so the same style rules apply to test code. Second, SpotBugs uses an exclude file. Keep that file small and document why each exclusion exists. An exclude file that grows without explanation is how a team quietly disables its own safety net.
Step 3: Place Each Check at the Right Point in the Lifecycle
Where a check runs determines how fast developers get feedback. A reasonable split looks like this:
| Check | When it runs | Why |
|---|---|---|
| Compiler warnings and Error Prone | Every compile | Instant feedback in the IDE |
| Checkstyle |
validate or verify
|
Fast, no need for compiled classes |
| Unit tests | test |
Behavior of isolated units |
| ArchUnit rules |
test (as JUnit tests) |
Architecture is verified alongside behavior |
| SpotBugs | verify |
Needs compiled bytecode, slower |
| Integration tests | verify |
Slowest, run last |
The principle is to put cheap checks early and expensive checks late. If SpotBugs takes four minutes on a large module, you do not want it running before every unit test during local development. Many teams run the fast checks on every push and the full suite on pull requests and nightly builds.
ArchUnit deserves special attention because it fits naturally into the test phase. Here is a rule that prevents domain code from depending on web code:
import com.tngtech.archunit.core.importer.ImportOption;
import com.tngtech.archunit.junit.AnalyzeClasses;
import com.tngtech.archunit.junit.ArchTest;
import com.tngtech.archunit.lang.ArchRule;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
@AnalyzeClasses(packages = "com.example.orders",
importOptions = ImportOption.DoNotIncludeTests.class)
class ArchitectureRulesTest {
@ArchTest
static final ArchRule domainMustNotDependOnWeb =
noClasses()
.that().resideInAPackage("..domain..")
.should().dependOnClassesThat()
.resideInAPackage("..web..");
}
When this rule fails, the build fails with a message that names the offending classes. That is far more useful than a comment in a design document that nobody reads.
Step 4: Lint Your Test Code Too
Test code is production code for the people who maintain it, and it often has its own failure patterns. Checkstyle and PMD can help with several of them, but a few rules are worth enforcing deliberately:
-
No
Thread.sleep()in tests. Sleeps are the most common cause of flaky tests. Use explicit waits, latches, or a library such as Awaitility, which polls a condition with a timeout. - No empty catch blocks in tests. An exception swallowed in a test hides the failure.
- Every test has at least one assertion. A test that only calls code and never checks the result proves nothing.
- No hard-coded ports or absolute file paths. Use random ports and temporary directories so tests run on any machine.
Some of these rules need a custom check. PMD supports custom rules written in XPath, and ArchUnit can enforce structural rules such as "test classes must end with Test." Here is a simple example of a flaky pattern and its replacement:
// Flagged by lint: fixed sleep makes the test timing-dependent
@Test
void orderIsProcessedAsync() throws InterruptedException {
orderService.submit(order);
Thread.sleep(2000);
assertEquals(OrderStatus.PROCESSED, repository.find(order.id()).status());
}
// Preferred: poll the condition with a timeout
@Test
void orderIsProcessedAsync() {
orderService.submit(order);
await().atMost(Duration.ofSeconds(5))
.untilAsserted(() ->
assertEquals(OrderStatus.PROCESSED,
repository.find(order.id()).status()));
}
The second version runs as fast as the system allows and fails with a clear timeout message instead of an intermittent assertion error.
Step 5: Enforce the Rules in CI and Locally
Lint only works if people cannot ignore it. There are two enforcement points, and both matter.
Locally, use a pre-commit hook or a build step that runs the fast checks before a commit or push. Tools such as Husky (for mixed repositories) or a simple Git hook script can run mvn -q checkstyle:check in a few seconds. The goal is to catch problems before they reach a pull request.
In CI, make the lint jobs required status checks. A pull request should not merge with a failing lint step. Here is a GitHub Actions job that runs the full verification on each pull request:
name: verify
on:
pull_request:
branches: [ main ]
jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '21'
cache: maven
- name: Run tests and static analysis
run: mvn -B verify
- name: Upload SpotBugs report on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: spotbugs-report
path: '**/target/spotbugsXml.xml'
Uploading the report on failure matters. A developer who sees "build failed" needs to know which rule failed and where, and the report answers that question without a second investigation.
Step 6: Handle Legacy Code with Baselines
The hardest part of adding lint to an existing project is not the configuration. It is the first run. A mature codebase can produce thousands of violations, and fixing them all before enabling the gate is rarely realistic.
The practical approach is a baseline. You record the current violations, accept them as the starting point, and fail the build only on new violations. Several tools support this directly, and for tools that do not, you can achieve the same effect by comparing the current report with a committed baseline file.
The rules I follow for baselines are:
- Freeze the current state. Commit the baseline and document the date.
- Block new violations immediately. Any change that adds a violation fails the build.
- Reduce the baseline gradually. When a developer touches a file, they fix the violations in that file.
- Track the count over time. A dashboard that shows the number of violations per module makes progress visible and keeps the team motivated.
This approach avoids the most common failure mode, which is a team that disables the gate because the backlog is too large to face.
Step 7: Tune Rules and Measure Their Value
A lint rule that produces constant false positives trains developers to ignore warnings, and that is worse than having no rule at all. Review the violations regularly, and ask three questions about each noisy rule:
- Is this rule catching real problems in our codebase?
- Are developers fixing the violations or suppressing them?
- Would a code reviewer have caught this anyway?
When a rule fails these tests, change its severity, narrow its scope, or remove it. Suppressions should be rare and visible. If the team uses @SuppressWarnings or a Checkstyle suppression file, review those suppressions during retrospectives. A large number of them is a signal that the rule set does not fit the code.
Measure the outcomes that matter to the team: the number of flaky tests, the time from pull request to merge, and the number of defects found in review versus production. Lint is a means to those ends, and it should be judged by them.
Common Pitfalls
Running everything on every keystroke. Full static analysis in the IDE on every save slows developers down. Keep the IDE feedback fast and move the expensive checks to the build.
Treating warnings as errors everywhere. Start with errors for high-confidence rules and warnings for style. Promote warnings to errors only after the team has fixed the existing ones.
Forgetting test code. Many teams lint only src/main. Test code deserves the same attention, especially for flaky patterns.
Config drift between modules. In a multi-module Maven project, define the plugin configuration once in the parent POM, and let child modules inherit it. Different rules in different modules confuse developers and make reports hard to compare.
Suppressing instead of fixing. A @SuppressWarnings annotation is sometimes correct, but it should come with a comment explaining why. Without one, it is a way to hide problems.
Adding tools without an owner. Someone on the team should own the lint configuration, review rule changes, and keep the baseline up to date. If nobody owns it, it decays.
A Practical Rollout Plan
If you are starting from zero, this sequence works for most Java teams:
- Week one: add Checkstyle with a minimal rule set, in warning mode. Share the report with the team.
-
Week two: add SpotBugs in
verifywith a baseline. Block new violations only. - Week three: add ArchUnit rules for your most important architectural boundaries.
-
Week four: enable the test-code rules, starting with flaky patterns such as
Thread.sleep. - Ongoing: review the baseline count monthly, tune noisy rules, and promote warnings to errors as the backlog shrinks.
The rollout is deliberately slow. Each step gives the team time to adapt, and each step has a clear success criterion.
Key Takeaways
- Linting and testing are complementary. Linting removes whole categories of problems so tests can focus on behavior.
- Choose a small set of tools with distinct purposes: Checkstyle for style, SpotBugs for likely bugs, ArchUnit for architecture.
- Place cheap checks early in the lifecycle and expensive checks late, so developers get fast feedback.
- Lint test code too, especially for flaky patterns such as fixed sleeps.
- Enforce rules both locally and in CI, and make lint jobs required status checks.
- Use baselines to adopt lint in legacy code without freezing the team.
- Tune noisy rules regularly and judge the rule set by its effect on defects and flakiness, not by the number of violations it reports.
Lint will not make a codebase correct, but it makes incorrect code harder to hide. Start small, enforce the rules you agree on, and let the build do the repetitive work so your reviewers and tests can focus on what matters.
Top comments (0)