Flaky Test Quarantine: Owners, Expiry, and Nonblocking CI Results
A confirmed flaky test can block unrelated changes without providing a dependable release signal. Removing that obstruction is reasonable only if someone still sees…
A failing test should help you identify a broken expectation. When it reports an unexplained boolean, mixes several unrelated behaviors, or quietly avoids an assertion, maintaining the test becomes a separate debugging task. A green result can be difficult to interpret too: did the test check the required behavior, or did its control flow skip the check?
Test smells are signals that test code may be difficult to understand, diagnose, or maintain. They invite review, rather than proving that every flagged test is incorrect. The academic test-smell catalog names assertion roulette, eager tests, and conditional test logic among these patterns. Their detection rules help locate candidates; context determines whether a change is useful.
A confusing test can fail consistently, and an intermittently failing test can have an environmental cause. Do not treat these maintainability smells as a complete explanation of flakiness. Review what the test promises, how it checks that promise, and what its failure tells the next engineer.
Refactoring test code starts with an oracle: the expected outcome that decides whether a result is correct. Write that outcome independently of the implementation. Otherwise, a tidier test can repeat the production mistake or lose an important case without making the loss obvious.
The examples below use PARCEL-SMELLS-1.0, a deliberately fictional parcel policy with synthetic inputs. Its supported example regions are local and remote. The behavior packet requires:
These are five specific expectations, with the two local-quote fields counted separately. The numbers are fixed requirements for this toy, not shipping prices or a performance measurement. Other regions and states are outside the demonstration's contract.
Save the following implementation as parcel.py. Its short methods make it possible to focus on test design rather than infrastructure.
class ParcelPolicy:
def quote(self, region):
fee = 5 if region == "local" else 9
return {"fee": fee, "currency": "USD"}
def label(self, token):
return "BOX-" + token
def can_cancel(self, state):
return state != "packed"
The test examples use Python's standard-library unittest. Put these imports at the top of smells_demo.py, then place the six test classes shown below underneath them. The before and after classes have distinct names so they can coexist. Run the examples with python -m unittest -v smells_demo from that directory; no external package is required.
import unittest
from parcel import ParcelPolicy
The exact example code was exercised locally using Python 3.13.12. The original classes passed three test methods, and the refactored classes passed five. Those counts describe this demonstration, not proof that the refactored suite covers more requirements. The important comparison is which inputs and expectations remain checked.
Assertion roulette appears when several poorly explained assertions make a failure difficult to interpret. The catalog focuses on multiple undocumented assertions. In practice, inspect the diagnostic output as well as the source: some assertion APIs already provide useful values and differences, while a generic boolean assertion discards that information.
Both assertions below describe one coherent outcome, a local shipping quote. The problem is their diagnostic expression, not the mere presence of two assertions.
class RouletteBefore(unittest.TestCase):
def test_quote_before(self):
quote = ParcelPolicy().quote("local")
self.assertTrue(quote["fee"] == 5)
self.assertTrue(quote["currency"] == "USD")
A traceback still identifies the failing line. However, False is not true says little about the violated requirement or the observed value. The reader must inspect the comparison and reconstruct its meaning.
class RouletteAfter(unittest.TestCase):
def test_quote_after(self):
quote = ParcelPolicy().quote("local")
self.assertEqual(quote["fee"], 5, "local shipping fee")
self.assertEqual(quote["currency"], "USD", "local quote currency")
The inputs and literal expected values are unchanged. Python's unittest documentation recommends specific assertion methods, such as assertEqual, over assertTrue applied to an equality comparison because they produce better failure messages. With the default longMessage setting, a custom message is appended to the standard diagnostic rather than replacing it.
In the local negative check, changing the quote currency to EUR made the before test fail with False is not true. The after test showed the unequal currency values and the message local quote currency. That is a concrete diagnostic improvement for this example, not a measured reduction in debugging time.
Keep related assertions together when they explain one outcome clearly. Splitting fee and currency into separate tests solely to satisfy a one-assertion rule can duplicate setup without clarifying the contract. Conversely, adding messages to a test that mixes unrelated behaviors does not resolve its overbroad purpose. That requires a different repair.
An eager test invokes several production methods in one test, making its purpose harder to understand. The catalog uses multiple production-method calls as a detection heuristic. Review whether those calls support one meaningful behavior or have been bundled because the object was already available.
class EagerBefore(unittest.TestCase):
def test_parcel_before(self):
policy = ParcelPolicy()
self.assertEqual(policy.label("toy"), "BOX-toy")
self.assertEqual(policy.can_cancel("packed"), False)
Label formatting and cancellation eligibility are independent requirements here. If label formatting fails first, the cancellation assertion is not reached in that test method. The test name also offers little indication of which behavior regressed.
class EagerAfter(unittest.TestCase):
def test_label_after(self):
policy = ParcelPolicy()
self.assertEqual(policy.label("toy"), "BOX-toy")
def test_cancellation_after(self):
policy = ParcelPolicy()
self.assertEqual(policy.can_cancel("packed"), False)
Both original assertions remain, with the same toy input, packed state, and literal expected results. Each method constructs its own policy object. Neither depends on a preceding test to create or modify shared state, so each can run alone.
For this demonstration, the focused after methods were also run individually and in reverse order. They passed in those executions. The useful design property is visible in the source: the arrangement for one behavior does not live in another test. In a larger suite, a per-test setup method can remove repeated arrangement while still creating fresh state for each test.
Do not split a genuine workflow test mechanically every time it calls several methods. An integration scenario may intentionally verify the relationship between operations. Its name, assertions, and setup should make that relationship explicit. The smell is a prompt to investigate unclear responsibility, not a rule that every test may call only one method.
Conditional test logic can change which assertions execute. The dangerous case is a test that lets the system's observed result decide whether that result deserves checking. A defect can then prevent its own assertion from running.
class ConditionalBefore(unittest.TestCase):
def test_fees_before(self):
policy = ParcelPolicy()
for region, expected in (("local", 5), ("remote", 9)):
fee = policy.quote(region)["fee"]
if fee:
self.assertEqual(fee, expected)
The table declares both intended cases and their independent expected fees. But the if condition checks the actual fee's truthiness. If the remote quote incorrectly returns zero, that iteration performs no assertion. The method can report success even though the remote requirement is violated.
Removing the remote row, replacing equality with a truthiness assertion, or weakening the expected fee would hide the problem further. The repair must retain the case and require its oracle to run.
class ConditionalAfter(unittest.TestCase):
def test_local_fee_after(self):
self.assertEqual(ParcelPolicy().quote("local")["fee"], 5)
def test_remote_fee_after(self):
self.assertEqual(ParcelPolicy().quote("remote")["fee"], 9)
Both local and remote inputs remain represented, and neither assertion depends on the returned fee being truthy. The expected numbers come from the behavior packet, not from applying the implementation's calculation inside the test.
The predeclared negative check changed only the remote fee to zero. The before conditional test passed; the after remote-fee test failed at its equality assertion. This change restores the intended requirement. It deliberately strengthens what the old test checked, so calling it identical old-test semantics would be misleading.
Distinct methods are readable for these two cases. For a larger fixed case table, bounded iteration with unittest subtests can identify each case and run the required assertion for every declared row. The framework documents this pattern. A loop over explicit cases is different from branching around an assertion because an actual result looks inconvenient.
Fixtures, cleanup, and test infrastructure can also need legitimate control flow. Review why a branch exists, whether its alternatives are intentional, and whether a required check can disappear. A blanket ban on every if statement would miss that reasoning.
These three test smell examples need different remedies. Better diagnostics address ambiguous assertions; focused tests clarify unrelated responsibilities; explicit case oracles prevent skipped requirements. Use the following questions to keep the repair aligned with its purpose.
Smell | Diagnostic Question | Preserve During Refactoring |
|---|---|---|
Assertion Roulette | Does the failure identify the violated expectation? | The same inputs and expected values |
Eager Test | Does one test combine unrelated behaviors? | Every behavior in independently arranged tests |
Conditional Test Logic | Can a branch prevent the required check? | Every intended case with an unconditional oracle |
A case-and-assertion ledger is more useful than comparing totals alone. For the quote diagnostic repair, retain local fee 5 and currency USD. For the eager-test split, retain BOX-toy and the rejection of cancellation for packed parcels. For the conditional repair, retain local fee 5 and remote fee 9, with both equality checks required to execute.
In this toy, separate mutations of the local fee, currency, label, packed-state cancellation result, and remote fee each made the corresponding after oracle fail at its intended assertion. No mutation failed because of missing setup or an import error. These checks challenge the five declared expectations; they do not establish complete coverage of a shipping system or the reliability of a customer test suite.
When reviewing a similar change in your repository:
For test automation maintenance, start with tests that are currently costly to interpret or change. A catalog flag can help find a candidate, but the acceptance criterion is concrete: every intended behavior remains represented, and a violated expectation produces an understandable failure. Keep that contract visible while improving the code around it.
A confirmed flaky test can block unrelated changes without providing a dependable release signal. Removing that obstruction is reasonable only if someone still sees…
A tester reviewing a Jira issue may need a fresh smoke run without opening GitLab and choosing pipeline parameters. A manually triggered Jira Cloud…
A regression-results worker receives HTTP 429 while reading Jira issues. Sleeping that worker may be necessary, but it will not help if other workers…
Create traceable test cases, prepare release-specific test runs, and keep approved coverage current as the product changes.