"The controller shall stop the pump quickly if the motor current is too high." Every reviewer nods at that sentence. Then someone has to write the test, and finds there’s nothing to test. How quickly? What’s too high? For how long? Which controller?
EARS, the Easy Approach to Requirements Syntax, fixes that with a handful of sentence patterns. Alistair Mavin and colleagues at Rolls-Royce developed it while untangling airworthiness regulations for a jet engine’s control system, and published it in 2009. It’s still plain English, but every requirement takes the same shape, and that shape makes the gaps obvious.
This post walks through the patterns, rewrites a realistic firmware draft with them, and checks the result with a small linter. Then it shows how each clause turns into part of a test.
TL;DR
- Every EARS requirement has the same shape: While precondition, when trigger, the system shall response, in that order.
- The keyword tells you the pattern: none (always), While (state), When (event), Where (optional feature), If/then (unwanted behavior), or a combination.
- Writing in EARS forces the decisions a vague requirement hides: the threshold, the duration, the deadline.
- A simple linter catches the usual problems: two "shall"s, no trigger, conditions after the response, vague words.
- Each clause maps to part of a test: While is setup, When or If is the stimulus, the response is the assertion.
The patterns
The generic shape comes straight from Mavin’s EARS page: zero or more preconditions, zero or one trigger, one system name, and one or more responses. The clauses always appear in the same order, because that’s the order things happen in.
Two details do most of the work:
- Name the system in every requirement. "The pump controller shall…", not "the system", not "the pump", and not a passive "the pump shall be stopped." Each requirement is then clearly about one thing you can test.
- Unwanted behavior gets its own keyword. Faults, bad input, and failures use If … then, so you can find every fault-handling requirement by searching for one word, and notice when there aren’t many.
A draft, linted
Here’s a first draft for a borehole pump controller, the kind of thing that gets written in a meeting:
PMP-001: The controller shall stop the pump quickly if the motor current is too high.
PMP-002: The pump shall start when the tank level drops below 20% and shall stop at 90%.
PMP-003: If the inlet pressure stays below 0.2 bar for 5 s, the pump controller shall stop the pump and raise a dry-run alarm.
PMP-004: The system should log faults.
PMP-005: The display shall be user-friendly.
PMP-006: While maintenance mode is active, the pump controller shall ignore start commands received over the network.
PMP-007: When the operator presses STOP the pump controller shall stop the pump within 100 ms.
PMP-008: Where a cellular modem is fitted, the pump controller shall send each alarm by SMS within 60 s.Code language: plaintext (plaintext)
Reviewing for structure is tedious and easy to automate, so here’s a small linter that does it. It splits off the leading clauses, finds "the system shall response", and checks the parts. Its core is two regular expressions:
CLAUSE = re.compile(r"(while|when|where|if)\b(.*?),\s*(?=(?:while|when|where|if|then|the)\b)", NOCASE)
MAIN = re.compile(r"(then\s+)?the\s+(.+?)\s+shall\s+(.+?)\.?$", NOCASE)Code language: Python (python)
On the draft:
$ python3 ears_lint.py --system 'pump controller' reqs-draft.txt; echo "exit $?"
PMP-001 ubiquitous subject is 'controller', not the pump controller
'if' after the response: move the condition to the front
vague: 'quickly'
vague: 'too high'
PMP-002 ubiquitous 2 x 'shall': split into 2 requirements
subject is 'pump', not the pump controller
'when' after the response: move the condition to the front
PMP-003 unwanted behaviour 'If' needs 'then' before the main clause
PMP-004 ubiquitous no 'shall' (uses 'should')
PMP-005 ubiquitous subject is 'display', not the pump controller
vague: 'user-friendly'
PMP-006 state-driven ok
PMP-007 (unparsed) can't find where the condition ends: put a comma before the main clause
PMP-008 optional feature ok
6 of the requirements need work
exit 1Code language: Shell Session (shell)
Look at how PMP-001 and PMP-002 are classified: "ubiquitous", meaning always active. That’s wrong, and it’s the most useful thing in the output. Both have a condition tacked on after the response, where it reads like an afterthought, so the linter can’t see it as a trigger. Putting the condition first, as EARS requires, makes the trigger impossible to miss and hard to leave vague.
The rewrite
Fixing the structure forces the real work. "Quickly" and "too high" have to become numbers, and the draft never said what they were. Someone who knows the motor has to decide. That decision is the point; the syntax just makes it unavoidable.
PMP-001: If the motor current exceeds 12 A for more than 200 ms, then the pump controller shall stop the pump within 50 ms.
PMP-002: When the tank level falls below 20 %, the pump controller shall start the pump.
PMP-009: When the tank level rises above 90 %, the pump controller shall stop the pump.
PMP-003: While the pump is running, if the inlet pressure stays below 0.2 bar for 5 s, then the pump controller shall stop the pump and raise a dry-run alarm.
PMP-004: When the pump controller detects a fault, the pump controller shall record the fault code and time in the fault log.
PMP-005: The pump controller shall show the tank level on the display in percent.
PMP-006: While maintenance mode is active, the pump controller shall ignore start commands received over the network.
PMP-007: When the operator presses STOP, the pump controller shall stop the pump within 100 ms.
PMP-008: Where a cellular modem is fitted, the pump controller shall send each alarm by SMS within 60 s.Code language: plaintext (plaintext)
A few changes beyond the obvious:
- PMP-002 became two requirements, because "start at 20 %" and "stop at 90 %" can pass or fail separately. The new one gets a new ID; the old ID keeps its meaning.
- PMP-003 gained a precondition. The draft would raise a dry-run alarm with the pump off, whenever the inlet pressure was low. That’s a design question the original wording hid.
- PMP-005 changed meaning. "User-friendly" can’t be verified, so it became something that can. If usability matters, it needs its own measurable requirements, not one adjective.
$ python3 ears_lint.py --system 'pump controller' reqs-ears.txt; echo "exit $?"
PMP-001 unwanted behaviour ok
PMP-002 event-driven ok
PMP-009 event-driven ok
PMP-003 complex ok
PMP-004 event-driven ok
PMP-005 ubiquitous ok
PMP-006 state-driven ok
PMP-007 event-driven ok
PMP-008 optional feature ok
all requirements follow EARS
exit 0Code language: Shell Session (shell)
It also checks clause order, since EARS puts the state before the trigger:
$ python3 ears_lint.py --system 'pump controller' /tmp/misordered.txt
PMP-010 complex 'While' must come before 'When'/'If'
1 of the requirements need workCode language: Shell Session (shell)
Passing the linter means the requirements are well-formed, not that they’re right. It can’t tell you whether 12 A is the correct limit or whether two requirements contradict each other. That’s still a review. Because the linter exits non-zero, though, you can run it in CI on the requirements file the same way you’d lint code.
Every clause is part of a test
An EARS requirement is close to a test plan already. The precondition is the setup, the trigger is the stimulus, and the response is what you assert:
Here it is against a toy model of the controller, with tests named for the requirement they verify:
def test_pmp_003_dry_run_stops_pump_after_5_s():
ctl = PumpController()
ctl.start() # While the pump is running,
run_low_pressure(ctl, 5000) # if the inlet pressure stays below 0.2 bar for 5 s,
assert not ctl.running # then the pump controller shall stop the pump
assert ctl.alarms == ["dry-run"] # and raise a dry-run alarm.
def test_pmp_003_does_not_trip_before_5_s():
ctl = PumpController()
ctl.start()
run_low_pressure(ctl, 4900)
assert ctl.running and not ctl.alarms
def test_pmp_003_needs_the_pump_running():
ctl = PumpController() # pump stopped: the While clause is false
run_low_pressure(ctl, 6000)
assert not ctl.alarmsCode language: Python (python)
$ python3 -m pytest -v test_pump.py 2>&1 | grep -E 'PASSED|FAILED|passed|failed'
test_pump.py::test_pmp_003_dry_run_stops_pump_after_5_s PASSED [ 25%]
test_pump.py::test_pmp_003_does_not_trip_before_5_s PASSED [ 50%]
test_pump.py::test_pmp_003_needs_the_pump_running PASSED [ 75%]
test_pump.py::test_pmp_006_maintenance_ignores_network_start PASSED [100%]
============================== 4 passed in 0.01s ===============================Code language: Shell Session (shell)
Since the linter already splits each requirement into its parts, it can print them as a starting point for a requirement-to-test matrix. The empty last column is where test names go:
$ python3 ears_lint.py --system 'pump controller' reqs-ears.txt --csv
id,pattern,precondition,trigger,response,test
PMP-001,unwanted behaviour,,the motor current exceeds 12 A for more than 200 ms,stop the pump within 50 ms,
PMP-002,event-driven,,the tank level falls below 20 %,start the pump,
PMP-009,event-driven,,the tank level rises above 90 %,stop the pump,
PMP-003,complex,the pump is running,the inlet pressure stays below 0.2 bar for 5 s,stop the pump and raise a dry-run alarm,
PMP-004,event-driven,,the pump controller detects a fault,record the fault code and time in the fault log,
PMP-005,ubiquitous,,,show the tank level on the display in percent,
PMP-006,state-driven,maintenance mode is active,,ignore start commands received over the network,
PMP-007,event-driven,,the operator presses STOP,stop the pump within 100 ms,
PMP-008,optional feature,a cellular modem is fitted,,send each alarm by SMS within 60 s,Code language: Shell Session (shell)
What EARS doesn’t do
EARS shapes individual sentences. It won’t tell you whether you have all the requirements you need, and it doesn’t record why each one says what it says. Keep a short rationale next to each requirement (why 12 A, where the 5 s came from), so the next person can change a number without guessing. And interface details, such as message formats, register maps, and timing on a bus, belong in an interface control document that the requirements point to, not in a hundred "shall" sentences.
Wrap-up
- Write every requirement as: While state, when trigger, the system shall response.
- Use If/then for faults and bad input, so you can find all of them.
- Name the system, use one "shall" per requirement, and put conditions first.
- Expect the rewrite to raise questions. The numbers the draft left out are decisions someone has to make.
- Lint requirements like code, then map each clause to a test’s setup, stimulus, and assertion.
Which requirement in your current spec would fail this linter first?
Keep going
If you want to take requirements further, into a model that traces each one to the design and the tests, that’s where The Model Is Not the Code picks up, the book I’m writing on SysML v2 for software engineers.
If you’d like help with a spec now, the Requirements & SOW Package delivers up to 60 testable requirements with rationale and a verification method for each, and the Test & Traceability Package maps the requirements you have to the tests you have and reports the gaps. See the Services page or get in touch.
Leave a Reply