How Unit Tests Protect Creativity and Speeds Up Development

30 Jun 2012 9 min read

The best developers I know fell in love with programming because of the sheer thrill of creation. On a computer, they could build programs of immense complexity, where every detail was under their control and the only limits were in their imagination. And then, with a few keystrokes, they could throw it all away and start again - trying out their latest idea with each iteration. Pure, unconstrained creativity.

Of course, there's a catch: bugs. They're an inevitable byproduct of complexity. We don't intend to create them, but complexity ensures we do, and they can quickly unravel our work unless we test to ensure that what we intended to build is what we actually delivered. Compared to the thrill of creation, testing can feel like a chore. But without it, every change becomes a gamble. Every refactor risks regression. Every release invites uncertainty. Testing is how we trade fragility for resilience.

But testing doesn't have to feel like a chore; in fact, it's through testing that we develop the discipline to turn raw creativity into lasting craftsmanship.

A photo of some bugs on a leaf

In this post, we'll look at unit testing. Unit testing isn't just about catching bugs - it's about building trust, with our code, our teammates, and our future selves. Unit tests let us move faster, refactor with confidence, and know that what we build today will still stand tomorrow. Testing doesn't diminish the thrill of creation - it preserves it, allowing us to keep building with confidence.

What is unit testing?

The short answer: a promise you make in test code that your production code has to fulfil.

Unit testing ensures that, given a particular app state, a specific and isolated part of your app behaves as expected. A unit can be anything, but ideally, it's as small as possible. In iOS, a method is typically considered a unit. Each test acts as a strict, written contract that the unit must satisfy for the test to pass. If the unit contains multiple paths (e.g., if and else branches), multiple unit tests are needed to ensure complete coverage.

Unit tests are grouped into test suites - known as regression suites - within a dedicated test target. These regression suites can have different focuses, e.g. all networking unit tests grouped into one suite. Each suite can be run on demand to ensure that new changes don't break existing functionality.

Each unit test should execute in isolation, unaffected by the state of other tests. It's the test's responsibility to ensure that any required conditions are met - such as providing test data, creating test doubles, accepting user permissions, and so on. Once the test has run, it must clean up any shared resources to avoid impacting other tests in the suite. This self-contained approach strictly controls the environment in which the unit test is run, ensuring that tests are repeatable and reliable.

Now that we understand what unit testing is, let's examine what we get from unit testing.

Why should I write unit tests?

The short answer: for so many reasons.

Unit tests don't just check correctness; they offer a range of benefits:

  • Unit tests run quickly and repeatedly, giving fast feedback and reducing manual testing effort.
  • Unit tests act as a first line of defence, surfacing issues before they reach production.
  • Unit tests identify exactly where and why a failure occurred, saving time and frustration.
  • Passing tests reassure developers, teams, and stakeholders that the system works as expected.
  • Unit tests give confidence to refactor implementations without altering behaviour.
  • Writing unit tests encourages modular, decoupled, and cleaner code design.
  • A regression suite preserves intent and prevents old bugs from resurfacing.
  • Unit tests confirm individual components work correctly, simplifying integration debugging.
  • Unit tests validate intended behaviour, ensuring code matches what you intended to build, not just what you happened to build.
  • Unit tests serve as living documentation that evolves with the codebase.
  • The effort of writing tests discourages unnecessary features and keeps the codebase focused.

For a deeper look into the above list, check out this post: How To Win Over a Unit Testing Sceptic.

Now that we understand what unit testing is, let's examine whether it's right for your project.

Does every project need unit testing?

The short answer: no.

It's true that many successful apps on the App Store started life without a single unit test. Early on, manual testing feels faster. The app is compact, consisting of just a few screens, and you can navigate through everything in a matter of minutes.

But then success arrives. Users love your app and start asking for more features. You add them, tweak old ones, and keep manually testing as you go. At first, it's fine. Then you hire another developer. They don't know the app as well as you do, so they stumble into bugs you've been unconsciously avoiding. Suddenly, crashes appear.

Each new release takes longer to test. Each new feature adds more paths to check. Eventually, testing becomes so time‑consuming that you cut corners. You trust your "perfect code", ship quickly...and wake up to one‑star reviews:

“App crashes all the time, avoid!!!”

Now you're firefighting. Days lost chasing down regressions. Sanity fraying. Your app's reputation is slipping all the time.

The problem isn't poor communication between team members or individual competence. It's that manual testing doesn't scale. Humans are great at creative problem‑solving, but we're terrible at repetitive tasks. We get bored, distracted, and sloppy. Computers, on the other hand, live for repetition.

That's where unit tests come in. By automating the boring, repetitive checks, we free ourselves to focus on the interesting edge cases. A test suite runs in minutes, not hours. It gives us confidence to refactor, add features, and move fast without fear. And when a new bug sneaks through, we add a test and make sure it never bites us again.

Unit tests won't magically make your app bug‑free - you can still write a terrible test suite that checks goat.length == 4 twelve hundred times and tell yourself that those tests are protecting you. But a good suite turns the grind of manual testing into a safety net that scales with your app.

Now that you've seen how unit testing can help scale your app, let's look at who should write them.

Who should write a unit test?

The short answer: you.

Your code isn't really “done” until it has tests that prove it works. Because unit tests are tightly coupled to the code they cover, the person who understands the functionality best - its author - is the best person to write its tests.

But here's the catch: developers can suffer from programming blindness. We stare at our own code for so long that we stop seeing its flaws - much like proofreading your own email and missing the typo in the subject line. That's why we need backup. Code reviews and pair programming are the extra set of eyes that catch the test coverage gaps we miss (and save us from embarrassing ourselves in production).

In Xcode, it's possible to see which lines of your production code are covered by your tests by opening the "Code Coverage" view - checking this when writing your tests is a great way to spot those gaps.

Writing good unit tests is a skill in its own right. At first, your tests might feel clumsy or too obvious - like training wheels on a bike. Stick with it. The more you practice, the more natural it becomes, and the payoff - confidence, resilience, and fewer late‑night bug hunts - more than makes up for any short-term discomfort.

Now that we know who should write the unit tests, let's look at when they should be written.

When should a unit test be written?

The short answer: as close as possible to when you write the code itself.

There are a few common approaches:

  • Before writing the code (TDD style): This can feel strange at first - you're writing a test for something that doesn't exist yet. But that's the point. Writing the test first forces you to think about the design of your method from the outside in. It nudges you toward code that's more cohesive, more isolated, and easier to reason about.
  • After writing the code: This can feel more natural because you already have something concrete to test. The downside? You may discover that your method is awkward to test - too many dependencies, too many branches - and end up refactoring what you just wrote.
  • While writing the code: A middle ground. You sketch out the method and the test side by side. The benefit is freshness: the purpose and structure of the method are still in your head, so writing the test feels easier.

There's no single “right” moment to write a test. But the closer you write it to the code itself, the more likely you are to spot design issues early - and the less likely you are to end up with hard-to-test, fragile code.

Now we've covered the what, who, when, and why. The last step? How - and the best way to learn is by doing.

How do we write unit tests?

The short answer: with practice.

iOS hasn't always been the most unit-test-friendly environment, but this has changed, with the XCTest framework a lot of the setup has now been automated away for us, making it easier than ever to write unit tests.

Let's work through an example together of how to add unit tests. We will be adding unit tests to:

@interface WBPerson: NSObject

@property (nonatomic, copy) NSString *name;
@property (nonatomic, assign) NSInteger age;

- (id)initWithName:(NSString *)name age:(NSInteger)age;

@end

@implementation WBPerson

#pragma - Init

- (id)initWithName:(NSString *)name age:(NSInteger)age
{
    self = [super init];

    if (self) {
        if (!([name length] > 1)) {
            return nil;
        }

        if (age <= 0) {
            return nil;
        }

        _name = name;
        _age = age;        
    }

    return self;
}

@end

WBPerson is our system's representation of a person. As you can see, to be considered a person, you need to have a name and an age.

Before we get to writing any tests, take a moment to look through initWithName:age: again and write down any possible tests that you can think of.

I'll wait.....

Ok, now that you've had the chance to think about it, let's list the possible paths:

  1. Create a valid person and check that that person is non-nil.
  2. Create a valid person and check name assignment.
  3. Create a valid person and check age assignment.
  4. Create a valid person with age equaling one and check age assignment.
  5. String with a length of one for the name parameter.
  6. Nil for name parameter.
  7. Zero value for age parameter.
  8. Minus value for age parameter.

The above list covers all possible paths through initWithName:age: - happy-paths and unhappy-paths. This systematic approach is what transforms testing from guesswork into confidence.

It's easy to fall into the trap of only focusing on the happy-paths, but unhappy-paths are just as important. Unit testing is a great way to test out those unhappy-path scenarios that would be really tricky to reproduce through manual testing.

So let's implement the above paths into unit tests.

@interface WBPersonTests: XCTestCase

@end

@implementation WBPersonTests

#pragma mark - Tests

- (void)test_givenAValidNameAndAge_whenIAttemptToCreateAPerson_thenThatPersonIsCreated
{
    WBPerson *person = [[WBPerson alloc] initWithName:@"johnny" age:26];

    XCTAssertNotNil(person, @"This person object should not be nil");
}

@end

And just like that, you've written your first unit test!

I always like to start with happy-path test cases, as it's good for my morale.

Let's look into the structure of the unit test and determine what it is actually doing. The assert XCTAssertNotNil is used when we want to check that an object is not nil. XCTAssertNotNil takes the object under test and a description. The description parameter is printed out to the console upon failure and should describe what has caused the failure in a human-readable manner. It can take a variable set of parameters in the same manner that stringWithFormat does.

Let's crack on and complete the other unit tests:

@implementation WBPersonTests

// Omitted other tests

- (void)test_givenAValidNameAndAge_whenIAttemptToCreateAPerson_thenNameIsCorrectlyAssigned
{
    NSString *name = @"johnny";
    WBPerson *person = [[WBPerson alloc] initWithName:name age:26];

    XCTAssertEqual(name, person.name, @"Person's name should be: %@ instead it is: %@", name, person.name);
}

- (void)test_givenAValidNameAndAge_whenIAttemptToCreateAPerson_thenAgeIsCorrectlyAssigned
{
    NSInteger age = 26;
    WBPerson *person = [[WBPerson alloc] initWithName:@"johnny" age:age];

    XCTAssertEqual(age, person.age, @"Person's age should be: %ld instead it is: %ld", (long)age, person.age);
}

- (void)test_givenNameIsNil_whenIAttemptToCreateAPerson_thenNilIsReturned
{
    WBPerson *person = [[WBPerson alloc] initWithName:nil age:26];

    XCTAssertNil(person, @"This person object should be nil");
}

- (void)test_givenNameLengthIsOnTheInvalidBoundary_whenIAttemptToCreateAPerson_thenNilIsReturned
{
    WBPerson *person = [[WBPerson alloc] initWithName:@"a" age:26];

    XCTAssertNil(person, @"This person object should be nil");
}

- (void)test_givenAgeIsOnTheInvalidBoundary_whenIAttemptToCreateAPerson_thenNilIsReturned
{
    WBPerson *person = [[WBPerson alloc] initWithName:@"johnny" age:0];

    XCTAssertNil(person, @"This person object should be nil");
}

- (void)test_givenAgeIsBelowTheInvalidBoundary_whenIAttemptToCreateAPerson_thenNilIsReturned
{
    WBPerson *person = [[WBPerson alloc] initWithName:@"johnny" age:-5];

    XCTAssertNil(person, @"This person object should be nil");
}

@end

It's important to note that in the above happy-paths, we are not testing to see that assigning to a property works properly - we are testing that the parameters are being assigned (without modification) to the correct properties.

Notice how these tests don’t just check correctness - they give us the confidence to change initWithName:age: later without fear. Now, if someone comes along and changes !([name length] > 1) to !([name length] > 0), a unit test will fail. That failure forces them to pause and answer the question: "Was this change intentional, or did I accidentally break something?". Without tests, they'd just keep going, potentially shipping a bug. Creating these reflection points in your development process gives you time to pause and question what you've just done - elevating what you create from code that works to code that lasts.

The above class is a trivial example, but I hope that you can see the power of unit tests in it.

Ready to write some more unit tests?

Unit testing doesn't replace the thrill of unconstrained creativity. It protects it. As your confidence grows, you discover your unit tests are more than automated checks - they're freedom. Freedom to keep experimenting, keep refactoring, keep creating, without fear of it all collapsing beneath you.

What do you think? Let me know by getting in touch on Mastodon or Bluesky.