liveBook · Manning

State-based testing

As explained in The Art of Unit Testing 3e

A state-based test verifies behavior by examining the observable state produced or changed by the code under test.

State-based testing checks an observable change caused by the code under test. Instead of asserting the entry point’s returned value, call another function or inspect a sibling property to see whether the operation produced the expected state. section 1.5 section 8.3.1

Use the exit point to choose the test technique:

  • Prefer a return-value test when the entry point directly provides the result. section 1.5
  • Use a state-based test when the operation changes behavior or data visible through a sibling function or property. section 8.3.1
  • Avoid replacing internal calls with mocks merely to verify that those calls happened; such tests overspecify implementation details. section 5.6.1 section 8.3.1

A state-based candidate

After invoking an entry point, make a follow-up call—or inspect an affected sibling property—and assert the resulting behavior. In PasswordVerifier4, verify() calls findFailedRules(), but no state affected by verify() is visible at the same scope, so this code is not a good candidate for state-based testing. Verifying the protected function call with a mock instead tests internal behavior and creates maintenance cost when implementation calls change. section 1.5 section 8.3.1

Listing 8.11 Production code that calls a protected function
export class PasswordVerifier4 {
  private _rules: ((input: string) => boolean)[];
  private _logger: IComplicatedLogger;
 
  constructor(rules: ((input) => boolean)[],
      logger: IComplicatedLogger) {
    this._rules = rules;
    this._logger = logger;
  }
 
  verify(input: string): boolean {
    const failed = this.findFailedRules(input);   #1
 
    if (failed.length === 0) {
      this._logger.info("PASSED");
      return true;
    }
    this._logger.info("FAIL");
    return false;
  }
 
  protected findFailedRules(input: string) {      #2
    const failed = this._rules
      .map((rule) => rule(input))
      .filter((result) => result === false);
    return failed;
  }
}
#1 Call to the internal function
#2 Internal function

Inspect the production flow from verify() to findFailedRules() to identify why an internal call is not itself the right state-based assertion point. section 8.3.1

How The Art of Unit Testing 3e covers it

How deep it goes
Defines indirect outputs, explains follow-up calls and sibling observables, and contrasts them with internal-call verification.
Examples
Uses follow-up calls, sibling functions or properties, and PasswordVerifier4’s protected findFailedRules() example.
What it assumes
Assumes familiarity with entry and exit points, while distinguishing state changes from return values and third-party calls.
What you can do after
Apply it by asserting observable state after invoking the entry point, while avoiding internal-call verification when possible.
How it connects
Connects return-value testing, mock objects, stubs, third-party dependencies, and interaction-based tests.

Also explained in

Compare how 2 books explain state-based testing →

Related concepts

This explainer was generated by Manning's knowledge graph from the text of The Art of Unit Testing 3e. The section links point to the parts of the book it draws on.