Skip to content
academia.sh

Lesson 01 / 19

Architectural Patterns

Three places the calculation can live relative to the screen, and six checks applied to the same correct and faulty version and measured in four separations.

Published Updated

In this lesson

  • Describes how keeping the calculation in the view controller, the view model, or the server layer changes the check
  • Measures whether a check tells the correct version from the faulty one by comparing two answers
  • Recognizes separating, blind, noisy, and inverted checks and explains why each one misleads
  • Shows that a recorded sample taken from the faulty version rejects the correct one and accepts the faulty one
Contents

In the earlier courses we measured the screen, the network, and concurrency against their own questions. This course builds on them and looks at how an app is organized, how it is tested, and how it is released. The three jobs share one thing: each of them places a check. A test looks at a return value, a UI test looks at text on the screen, a release pipeline looks at whether the build passed. Every check says either “pass” or “fail”. But a pass does not show that the app is correct.

Whether a check works only becomes visible when the same check is applied to a faulty version. So the course has a single question: does this check tell the correct version from the faulty one? We set up two versions, one check, and two answers. Four combinations give four separations:

  • separating: passes on the correct version, fails on the faulty version. The check catches the fault.
  • blind: passes on both versions. The check looks at something the fault does not touch.
  • noisy: fails on both versions. The check fails for a reason unrelated to the fault.
  • inverted: fails on the correct version, passes on the faulty version. The check’s expectation was taken from the faulty version.

In the output the four names are written in ASCII: separating, blind, noisy, inverted. The lesson never writes a category name by hand. A rule gives the category: the core compares the two answers the check gives for the two versions.

// Separation.swift — classifies whether a check tells the correct version from the faulty one
struct Contrast<Output: CustomStringConvertible> {
    let label: String
    let correct: Output
    let faulty: Output
    let passesCorrect: Bool
    let passesFaulty: Bool

    var separation: String {
        switch (passesCorrect, passesFaulty) {
        case (true, false): return "separating"
        case (true, true): return "blind"
        case (false, false): return "noisy"
        case (false, true): return "inverted"
        }
    }

    func describe() -> String {
        let onCorrect = passesCorrect ? "pass" : "fail"
        let onFaulty = passesFaulty ? "pass" : "fail"
        print("\(label) | correct=\(correct)/\(onCorrect) faulty=\(faulty)/\(onFaulty) -> \(separation)")
        return separation
    }
}

func contrast<Output>(_ label: String, correct: Output, faulty: Output, check: (Output) -> Bool) -> Contrast<Output> {
    Contrast(label: label, correct: correct, faulty: faulty, passesCorrect: check(correct), passesFaulty: check(faulty))
}

func countSeparations(_ results: [String]) {
    for name in ["separating", "blind", "noisy", "inverted"] {
        print("\(name): \(results.filter { $0 == name }.count)")
    }
}

contrast applies the same check, the check closure, to the output of both versions. The check is a single closure and it is identical for both versions; only the input differs. Contrast asks two questions: does the check pass on the correct version (passesCorrect), and does it pass on the faulty version (passesFaulty). Output can be any type that conforms to CustomStringConvertible. countSeparations counts the four names.

One limit of this measure should be known from the start: separation is measured against one fault and one input. A check coming out “separating” does not show that it catches every fault, only that it catches the fault the lesson set up. Each lesson will name the fault it sets up.

Separating the Calculation from the Screen

To measure separation, we first need to run one version in isolation. If the calculation lives inside the screen, that is not possible. This is the first job of architectural patterns in this course: where the calculation lives determines what it costs to check it.

The first variant keeps the calculation in the view controller. The cart total, the discount, and the receipt text are all computed inside the class that draws the screen.

import UIKit

// Calculation inside the screen — KAYNAK
final class CartViewController: UIViewController {
    private let totalLabel = UILabel()
    private var lines: [(name: String, price: Int)] = []

    override func viewDidLoad() {
        super.viewDidLoad()
        let sum = lines.reduce(0) { $0 + $1.price }
        let total = sum - sum * 10 / 100
        totalLabel.text = "Total: \(total) TL"
    }
}

This fragment is not run; the view controller depends on the platform’s lifecycle and the lesson claims no output for it. What matters is its structure. To check total, viewDidLoad has to run, which means a screen has to be built. To check the faulty version (the one that applies the discount twice), you would need two separate copies of the screen. Checking the calculation becomes the same job as checking the screen.

The second variant moves the calculation into the view model. The view model produces the values the screen needs to know, and the screen only displays them.

// Calculation in the view model — KAYNAK
struct CartViewModel {
    let lines: [Line]
    let percent: Int

    var total: Int { correctTotal(lines, percent: percent) }
    var receiptText: String { receipt(total) }
}

Because the view model is a plain value, it can be called without building a screen. This lesson’s measurement rests on this variant: correctTotal and faultyTotal below are screenless copies of the calculation in the view controller. Separating the calculation makes it cheap to give the same check to two versions.

The third variant leaves the calculation to the server layer: the app does not compute the total itself, it fetches it with a request. Checking the calculation then requires a network call. A check that depends on the network fails when there is no network, and when there is one, it depends on the server’s version at that moment. We will measure this problem in the fourth lesson, when we cover handing a dependency in from outside.

None of the three variants is right on its own. Which one to pick depends on what each check costs. This lesson fixes only one thing: the more a check depends on the screen, the more expensive it is to compare two versions.

Six Checks, Four Separations

The fault is this: the discount is applied twice. The correct version gives 90 TL for a 100 TL cart with a ten percent discount, and the faulty version gives 81 TL. Six checks are applied to the same two versions. The names of the checks and the values they verify are the names and expected values of the tests that would be written in a test target.

// main.swift — six checks, four separations
struct Line {
    let name: String
    let price: Int
}

func correctTotal(_ lines: [Line], percent: Int) -> Int {
    let sum = lines.reduce(0) { $0 + $1.price }
    return sum - sum * percent / 100
}

func faultyTotal(_ lines: [Line], percent: Int) -> Int {
    let sum = lines.reduce(0) { $0 + $1.price }
    let discounted = sum - sum * percent / 100
    return discounted - discounted * percent / 100
}

func receipt(_ total: Int) -> String { "Total: \(total) TL" }

let cart = [Line(name: "notebook", price: 40), Line(name: "pen", price: 25), Line(name: "eraser", price: 35)]
let good = correctTotal(cart, percent: 10)
let bad = faultyTotal(cart, percent: 10)

let exact = contrast("total-equals", correct: good, faulty: bad) { $0 == 90 }.describe()
let discount = contrast("discount-amount", correct: 100 - good, faulty: 100 - bad) { $0 == 10 }.describe()
let positive = contrast("positive-total", correct: good, faulty: bad) { $0 > 0 }.describe()
let rows = contrast("row-count", correct: cart.count, faulty: cart.count) { $0 == 3 }.describe()
let text = contrast("receipt-text", correct: receipt(good), faulty: receipt(bad)) { $0 == "Total: 90.00 TL" }.describe()
let golden = contrast("recorded-sample", correct: good, faulty: bad) { $0 == 81 }.describe()

countSeparations([exact, discount, positive, rows, text, golden])
total-equals | correct=90/pass faulty=81/fail -> separating
discount-amount | correct=10/pass faulty=19/fail -> separating
positive-total | correct=90/pass faulty=81/pass -> blind
row-count | correct=3/pass faulty=3/pass -> blind
receipt-text | correct=Total: 90 TL/fail faulty=Total: 81 TL/fail -> noisy
recorded-sample | correct=90/fail faulty=81/pass -> inverted
separating: 2
blind: 2
noisy: 1
inverted: 1

The output was run three times in the sandbox on Swift 6.4 and is identical in all three. The six checks produced all four separations: 2 separating, 2 blind, 1 noisy, 1 inverted.

total-equals and discount-amount look at the value the fault changes. The first expects the total to be 90, the second expects the discount to be 10; the faulty version gives 81 and 19, so both fail. These are separating.

positive-total looks at a property the fault does not break: 81 is also greater than zero. row-count looks at a value the fault never touches: the discount does not change the row count. Both pass on both versions, so both are blind. A blind check misleads because it looks green. A code coverage report would count both of these checks as executed lines; the number in the report does not show the difference between separating and blind, only the separation does.

receipt-text wrote its expected text in the old format, 90.00. The receipt prints Total: 90 TL, so the check fails on the correct version as well. It fails on the faulty version too. The check does not catch the fault; it fails not because of the fault but because of the check itself. This is noisy. A noisy check is eventually silenced with “this test is always red”, and a silenced check cannot catch a real fault later either.

recorded-sample recorded its expected value from the faulty version: 81. The correct version gives 90 and the check rejects it; the faulty version gives 81 and the check accepts it. This is an inverted check, and it is the most dangerous of the four. The faulty version passes green while the correct version goes red. The typical cause is that the recorded sample (a golden file, a snapshot) was taken from a faulty version; whoever recorded it looked at the output and said “that looks right”.

This separation has one more cost: noisy and inverted are the fault of whoever wrote the check, not of the code. Looking for the fault in the code is looking in the wrong place.

Where the Expected Value Comes From

Four of the six checks failed at the same place: the source of the expected value. positive-total and row-count took their expectation from a property the fault does not break, receipt-text from an old format, and recorded-sample from the faulty version’s own output. The two separating checks took their expectation not from the code but from the rule: a 100 TL cart with a ten percent discount comes to 90 TL. This difference looks small at first, but it has large consequences in practice. An expectation taken from the rule is independent of the code; changing the code does not affect it, and breaking the code breaks it. An expectation taken from the code breaks together with the code.

So the first question to ask when writing a check should be “what should this value be”, not “what value does the code give”. The second turns the check into a mirror of the code, as in the recorded sample, and the mirror reflects the fault too.

Mutation testing, which asks whether a test suite catches deliberately broken code, is the closest relative of this measure. The difference is this: mutation testing only asks “was it caught”. Here the result on the correct version is measured too, so noisy and inverted checks show up separately from the blind check. A check can be green or red in all three cases, but the reason behind each is different and so is each remedy: a blind check gets a second input, a noisy check gets its expectation corrected, an inverted check has its recording retaken from the correct version.

Assumptions

AR1. correctTotal and faultyTotal are screenless copies of the calculation in the view controller. This lesson does not measure whether the real screen gives the same result; the screen stays KAYNAK.

AR2. Amounts are whole TL and division rounds down. Fractions of a lira, the rounding mode, and a currency type do not enter the model; the noise in the receipt-text check therefore comes only from the text format.

AR3. The checks do not run in a real test framework. Each check is a closure that stands for the work a test does; test names and expected values are kept the same as the tests in the framework.

The Bounding Measurement

The same check can be blind on one input and separating on another against the same fault. Here the fault is different: the percentage is applied rounded down to a multiple of ten.

func correctPrice(_ sum: Int, percent: Int) -> Int { sum - sum * percent / 100 }
func roundedPrice(_ sum: Int, percent: Int) -> Int { sum - sum * (percent / 10 * 10) / 100 }

let ten = contrast("ten-percent", correct: correctPrice(100, percent: 10), faulty: roundedPrice(100, percent: 10)) { $0 == 90 }.describe()
let fifteen = contrast("fifteen-percent", correct: correctPrice(100, percent: 15), faulty: roundedPrice(100, percent: 15)) { $0 == 85 }.describe()
countSeparations([ten, fifteen])
ten-percent | correct=90/pass faulty=90/pass -> blind
fifteen-percent | correct=85/pass faulty=90/fail -> separating
separating: 1
blind: 1
noisy: 0
inverted: 0

The output is identical across three runs. Ten percent is the input where the fault is invisible, because rounding 10 down to a multiple of ten gives 10. On this input the same check stays blind. At fifteen percent the fault shows: 15 drops to 10 and the check fails. The check itself did not change; only the input did. What this measurement says is that whether a check is “blind” or “separating” is a property of the input as much as of the check. A test written with a single input may never have seen the input where the fault lives.

Summary

  • If the calculation is in the view controller, checking it requires building a screen; once it moves to the view model, the two versions are compared without a screen. A calculation in the server layer depends on the network; we cover that in the fourth lesson.
  • A check’s value comes from the two answers it gives to the correct and faulty versions: whether it passes says nothing by itself.
  • Six checks produced four separations: 2 separating, 2 blind, 1 noisy, 1 inverted.
  • A blind check looks green and gives false confidence; an inverted check lets the faulty version pass and breaks the correct one.
  • The same check can be blind on one input and separating on another: blind at ten percent, separating at fifteen percent.
  • This lesson’s separation: all four (2/2/1/1).

Next Step

Separating the calculation from the screen only helps the calculation; the transitions between screens may still live inside the screens. In the second lesson, The Coordinator Pattern takes navigation away from the views and gives it to a separate object. There the check will look not at a value on one screen but at which screen opens after payment, and against a fault that opens the wrong screen after payment, two checks will answer differently.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close