Six hundred controllers, no tests, no freeze
A legacy platform got a test suite without anyone writing the tests
- Client
- A production Grails lending platform
- Engagement
- Retainer
- Period
- 2026
The problem
The platform had been running for fifteen years. Six hundred and thirty one controllers, a decade of business rules encoded in Grails domain constraints and GSP views, and not a single test.
That is a normal position for a system of this age, and it is the one thing that makes modernisation genuinely dangerous. Every change is unverifiable. The usual answers are both bad: freeze features for a year while a team writes tests by hand, or change the code and find out in production.
Neither was on the table. The client needed the system to keep shipping while it was being worked on.
The observation
A codebase this old is badly documented but it is not silent. It describes its own test surface, in detail, in a form a machine can read.
Controllers declare which routes exist and which actions they accept. Views declare which form fields a page will render. Domain classes declare which constraints should fire and what the validation messages will be. URL mappings declare the shape of the whole application.
None of that had to be written down again. It only had to be read.
What we built
specminer reads the source, builds a specification of what the application says it does, then drives a real browser against the running system and checks whether the two agree.
It runs in five modes. It captures a visual baseline of every page and diffs against it on later runs. It verifies that the live DOM matches what the source declared. It fuzzes every form with boundary values and injection payloads. It executes business-rule scenarios written as YAML. And it does all of that in one sweep with a combined report.
The part that matters is that nobody wrote the page list, the field list or the constraint list. Those came out of the code.
| Measure | Result |
|---|---|
| Pages verified every page reachable from the route table | 73 of 73 |
| Forms fuzzed 1,494 boundary and injection inputs | 66 forms |
| Vulnerabilities found | 0 XSS, 0 SQLi |
| Form fields matching source | 91% |
| Table columns matching source | 97% |
| Business rules passing | 12 of 12 |
| Visual baselines captured | 29 pages |
| Full suite runtime | ~3 min |
The source-to-DOM figures are the interesting ones. They are not a pass mark. Where the live page disagreed with what the code declared, that gap was itself the finding: dead fields, columns the view had stopped rendering, constraints that no longer fired.
Why not an existing tool
Playwright, Cypress and Selenium are automation engines. They will do whatever you tell them, and they assume you are going to write six hundred controllers’ worth of instructions yourself. That assumption was the whole problem.
Screenshot tools like BackstopJS, Percy and Applitools compare pixels. They can tell you a page changed. They cannot tell you whether the page still matches what the code says it should do, because they never read the code.
specminer starts from the source. That is the difference, and it is the only reason the numbers above were reachable in the time available.
Business rules, written once
Extracted structure gets you coverage. It does not get you intent. Rules that only exist in someone’s head still have to be stated, so they are stated once, as data, and then run on every sweep.
scenarios:
- id: duplicate-username
name: Existing username rejected
preconditions: [open_add_user]
steps:
- action: fill
target: "[name='username']"
value: "admin"
- action: select
target: "[name='role']"
value: "User"
- action: submit
expect:
type: error_message
contains: "already exists"
screenshot: trueNot a Grails tool
The framework-specific part is isolated in an adapter. An adapter knows how one framework declares its routes, views, fields and constraints, and returns a specification. Everything downstream of that is framework-agnostic.
The Grails adapter is production-tested. An OpenAPI adapter works. A Spring adapter is a stub. A new one is a class with a single method.
class DjangoAdapter(BaseAdapter):
name = "django"
def generate_spec(self, source_path: Path) -> TestSpec:
# Read urls.py, views.py, models.py, templates/
# Return TestSpec with pages, forms, tables
...What it changed
The team could modify a fifteen-year-old system and know within three minutes whether anything visible had moved. Not proof of correctness, and we have never claimed it is. But it turned a category of silent breakage into a category of loud, immediate, specific breakage, on a codebase where the alternative was finding out from a user.
The tool outlived the engagement. It is ours, it is open, and it works on any application someone writes an adapter for.
Got something like this on your plate?
One call with an engineer, not a salesperson.