Years ago, while Kent Beck was at Facebook, he posted a short note called Decompose Run-On Tests. The original images are long gone and it no longer shows up in search results. But it changed how I write and maintain test suites. It remains one of my most shared posts when I talk about testing in Swift.

Here’s the idea, plus some Swift Testing examples to make it concrete.

What a run-on test is

Beck borrows the term from grammar. A run-on sentence has at least two parts, either one of which could stand on its own, jammed together anyway. A run-on test looks like this:

set up
assert
operation
assert
operation
assert
operation
assert

One test, one name, four separate claims about the system buried inside it.

Why run-on tests are a problem

When a run-on test fails, you don’t know what’s wrong. If one of the later assertions fails, is the bug in the operation that just ran? Or did an earlier operation leave the world in a weird state three steps ago? A failed run-on hands you a debugging problem instead of an answer.

Beck’s theory on how we end up here rings true: you write these while exploring. “I’d like an object like this.” Done. “Oh, it needs an operation like that.” Done. “But that means it could have another operation like this.” Done. Nothing wrong with any single step. The problem is you never went back and cleaned up.

When a design arrives fully formed, as an algebra of things and operations that compose, you don’t write run-ons. You write small tests for small pieces. When every step is a revelation, the easiest place to stick the next assertion is onto the end of the test you already have open. That’s how a test suite ends up with one 400-line test holding half the coverage.

There’s nothing wrong with coding in discovery mode. It’s a useful skill to make progress in a dark room without breaking anything. It’s not okay to leave the evidence of that discovery lying around for your teammates to trip over.

How to decompose one

Beck’s process, and it still holds up:

1. Copy the test. Delete everything after the first assertion.

set up
assert

Name it like a reader who just watched it fail. Not testInitialization (initialization of what? how could it go wrong?). Something like initializationProducesConsistentNamesAndExperiments. Now you know where to look.

Test names don’t play by the same rules as other function names. You type the name once. You never call it from other code. The only place anyone reads it is in a failure message, so a long, explanatory name is correct, not excessive. (Beck credits BDD for this.)

2. Copy the test again. Delete the first assertion and everything after the second.

set up
operation
assert

Check the setup. Is any of it irrelevant to this assertion? Delete it. Name this one too.

3. Repeat until you reach the last assertion, stripping redundant operations as you go. If you notice a path that isn’t covered along the way, write it down and add it later rather than trying to handle it mid-decomposition.

4. Delete the original run-on test. This is the satisfying part. You’re left with a handful of short, well-named tests, each one named after the specific thing that could go wrong.

One thing that trips people up every time I walk through this: the resulting suite has exactly the same coverage as the original run-on, but only if you run all the tests together and keep all of them green. The third, fourth, and fifth tests in the sequence don’t carry the earlier assertions anymore. That feels wrong the first time you do it. It’s fine. If something regresses, a different test in the suite will catch it. Trusting the suite as a whole, instead of demanding every individual test re-prove everything, is what makes this simplification safe.

The same thing in Swift Testing

Here’s a run-on test against a small Playlist type (add songs, play them in order, advance, shuffle), written with Swift Testing:

import Testing
@testable import RunOnTestsDemo

// BEFORE: a run-on test that chains every operation and assertion together.
@Test func playlist() {
    let hangups = Song(title: "Hang Up Your Hangups")
    let cantaloupeIsland = Song(title: "Cantaloupe Island")
    let rockit = Song(title: "Rockit")

    let playlist = Playlist(name: "Herbie's Road Trip", shuffle: { $0.reversed() })
    #expect(playlist.name == "Herbie's Road Trip")
    #expect(playlist.queue.isEmpty)
    #expect(playlist.isPlaying == false)

    playlist.add(hangups)
    playlist.add(cantaloupeIsland)
    playlist.add(rockit)
    #expect(playlist.queue == [hangups, cantaloupeIsland, rockit])

    playlist.play()
    #expect(playlist.isPlaying == true)
    #expect(playlist.nowPlaying == hangups)
    #expect(playlist.upNext == cantaloupeIsland)

    playlist.next()
    #expect(playlist.nowPlaying == cantaloupeIsland)
    #expect(playlist.upNext == rockit)

    playlist.next()
    #expect(playlist.nowPlaying == rockit)
    #expect(playlist.upNext == nil)

    playlist.next()
    #expect(playlist.isPlaying == false)
    #expect(playlist.nowPlaying == nil)

    playlist.play(mode: .shuffled)
    #expect(playlist.nowPlaying == rockit)
    #expect(playlist.upNext == cantaloupeIsland)
}

One @Test, sixteen assertions, eight operations threaded through them. If #expect(playlist.upNext == cantaloupeIsland) fails on the last line, is shuffling broken? Did next() leave playOrder in a bad state three calls earlier? Did queuing the songs in the wrong order cause it? You can’t tell without rereading the whole thing from the top, and neither can whoever’s on call when this fails in CI at 2 a.m.

Run it through Beck’s process and you get this instead:

import Testing
@testable import RunOnTestsDemo

private let hangups = Song(title: "Hang Up Your Hangups")
private let cantaloupeIsland = Song(title: "Cantaloupe Island")
private let rockit = Song(title: "Rockit")

// AFTER: the run-on test decomposed into focused tests.
// swift-testing creates a fresh struct instance per test, so no setUp is needed.
struct PlaylistTests {
    // Reversing stands in for random shuffling so shuffled playback is deterministic.
    let playlist = Playlist(name: "Herbie's Road Trip", shuffle: { $0.reversed() })

    private func queueSongs() {
        playlist.add(hangups)
        playlist.add(cantaloupeIsland)
        playlist.add(rockit)
    }

    @Test func newPlaylistHasNameAndEmptyQueueAndIsNotPlaying() {
        #expect(playlist.name == "Herbie's Road Trip")
        #expect(playlist.queue.isEmpty)
        #expect(playlist.isPlaying == false)
    }

    @Test func addingSongsQueuesThemInOrder() {
        queueSongs()

        #expect(playlist.queue == [hangups, cantaloupeIsland, rockit])
    }

    @Test func playingInOrderStartsWithTheFirstQueuedSong() {
        queueSongs()

        playlist.play()

        #expect(playlist.isPlaying == true)
        #expect(playlist.nowPlaying == hangups)
    }

    @Test func upNextIsTheFollowingQueuedSong() {
        queueSongs()

        playlist.play()

        #expect(playlist.upNext == cantaloupeIsland)
    }

    @Test func advancingPlaysTheNextSong() {
        queueSongs()
        playlist.play()

        playlist.next()

        #expect(playlist.nowPlaying == cantaloupeIsland)
        #expect(playlist.upNext == rockit)
    }

    @Test func lastSongHasNothingUpNext() {
        queueSongs()
        playlist.play()
        playlist.next()

        playlist.next()

        #expect(playlist.nowPlaying == rockit)
        #expect(playlist.upNext == nil)
    }

    @Test func advancingPastTheLastSongStopsPlayback() {
        queueSongs()
        playlist.play()
        playlist.next()
        playlist.next()

        playlist.next()

        #expect(playlist.isPlaying == false)
        #expect(playlist.nowPlaying == nil)
    }

    @Test func shuffledPlaybackUsesTheInjectedShuffleFunction() {
        queueSongs()

        playlist.play(mode: .shuffled)

        #expect(playlist.nowPlaying == rockit)
        #expect(playlist.upNext == cantaloupeIsland)
    }
}

Eight tests, each named after the one thing that could go wrong. shuffledPlaybackUsesTheInjectedShuffleFunction fails, you look at the shuffle closure. You don’t go reread next().

Do this to your own test suite

Go find the longest test in your codebase. It’s probably a run-on. Split it using the steps above, trust the suite to catch what the deleted assertions used to catch, and see how much nicer your next failed test run looks.

Next level

Doing this for a long sequence of events can result in a lot of duplicated code. A couple of ways to resolve this:

  1. Extract repeated code into a new setup func func loadQueueAndPlayThreeSongs(). This improves readability for complex test runs.
  2. Nested blocks. Use a nested struct and place setup code in the initializer. I miss how easy Quick made this when running tests from XCTest.

More on those options down the road.