Introduction
A few days ago I decided to use a small iOS trivia app about movie questions that I developed a while ago to practice testing best practices, especially ATDD (Acceptance Test-Driven Development), which I’m learning thanks to the CodelyTV course Testing: Introduction and best practices.
In this post I want to share how I detected and solved a small user experience problem in the score ranking screen, applying ATDD.
The detected problem
In the app, users can check a ranking with the best scores and, if they want, they can clear the ranking by pressing a button. The problem is that, after clearing the ranking, the screen doesn’t update automatically: you have to exit and come back to see that the records have actually been deleted.
The goal: that when pressing the clear button, the score list would empty instantly.
Why ATDD?
ATDD (Acceptance Test-Driven Development) is a methodology that focuses on first defining the expected behavior from the user’s point of view, before implementing the technical solution. It seemed like the perfect excuse to practice it in this case, since the problem was very specific and directly affected user experience.
Writing the acceptance test
Following the ATDD philosophy, the first thing I did was write an acceptance test that describes the expected behavior from the user’s point of view. In this case, I want to make sure that, when pressing the button to clear the ranking, the score list disappears and the empty view is shown, without needing to manually reload the screen.
- To be able to test this functionality well, I added a ranking mock with 3 initial results. This way I can check that the list is displayed correctly at the beginning and that it really empties after pressing the button.
The test looks like this:
func testClearRankingLeavesListEmpty() {
let app = XCUIApplication()
app.launchArguments.append("--mockRanking")
app.launch()
// Check that there are three elements in the ranking at the beginning
let rankingList = app.collectionViews["ranking_list"]
XCTAssertEqual(rankingList.cells.count, 3) // We expect 3 cells
// Press the button to clear the ranking
let cleanButton = app.buttons["clean_ranking_button"]
cleanButton.tap()
// Confirm deletion in the alert
let confirmButton = app.buttons["Delete"]
confirmButton.tap()
// Check that the list no longer exists (the UI should update)
XCTAssertFalse(rankingList.waitForExistence(timeout: 1))
// Check that the empty view is displayed
let emptyViewHeadline = app.staticTexts["empty_ranking_headline"]
XCTAssert(emptyViewHeadline.waitForExistence(timeout: 1))
}
This test must fail the first time we run it, since we haven’t implemented the logic yet. This failure assures us that the test is actually checking the behavior we want to achieve.
Implementing the logic: entering the TDD cycle
Once I have the failing acceptance test, it’s time to enter the classic TDD cycle to implement the necessary logic that will make the test pass. This is where you start working from the inside out: first you write unit tests on the internal logic, check that everything works, and these changes should make the acceptance test pass.
Unit test for internal logic
This unit test ensures that the function that clears the ranking works correctly in the ViewModel.
The test looks like this:
func testEraseRanking() {
// Given: a ranking with three elements
viewModel.ranking = [
Score(name: "PLAYER1", score: 123),
Score(name: "PLAYER2", score: 45),
Score(name: "PLAYER3", score: 34)
]
// When: I clear the ranking
viewModel.eraseRanking()
// Then: the ranking should be empty
XCTAssert(viewModel.ranking.isEmpty)
}
Same as before: this test must fail the first time I run it, since I haven’t implemented the logic yet… Wait, what? Why does the test pass?
A real case: when logic works but UI doesn’t react
Here I encountered a very interesting situation: The function to clear the ranking already existed and the unit test passed without problems, that is, the score array was emptied correctly. However, the acceptance test kept failing because the interface didn’t update after clearing the ranking.
These types of situations are what really justify the effort of writing acceptance tests: they help us ensure the app responds as the user expects, not just that methods do what they should internally.
The technical problem
After investigating a bit, I discovered the problem: I was testing that the ranking was deleted from the ViewModel’s var ranking: [Score] = [] property, but the ranking view had its own state logic that wasn’t properly connected to the ViewModel.
The solution was to add @Published to the ViewModel’s ranking so changes would automatically reflect in the UI. I didn’t have it before because I wasn’t using that variable to paint the data, but an internal view variable.
Imagine you have two copies of the same information:
- One in the ViewModel (which is what I was testing)
- Another in the view (which is what the user sees)
My unit test verified that the ViewModel’s copy was deleted, but the view kept showing its local copy. It’s like having two agendas: you delete an entry from your work agenda, but your personal agenda still shows the appointment because it didn’t know you had deleted it.
The solution: a single source of truth
The key was making sure the view always uses information from the ViewModel, not a local copy. I changed the structure so that:
- The ViewModel is the only source of truth for the ranking
- Ranking changes are reflected in the UI by adding
@Publishedto the ViewModel’s ranking property
This way, when the ranking is deleted from the viewModel, the view automatically knows about it and repaints.
Why is this important?
Without the acceptance test, it would never have been detected that although the logic worked, the user didn’t see the change. And without the unit test, I wouldn’t have had the confidence that the problem was in the connection between ViewModel and view, not in the deletion logic.
The passing acceptance test
Once the solution was implemented, the acceptance test finally passes:
Now yes, the UI reacts correctly and the test passes!
Final reflection
Practicing what you’ve learned is the best way for concepts to really stick with you. It’s not the same to read about ATDD and TDD as to face a real problem and see how these methodologies help you solve it step by step. This project is old and has a thousand things that could be improved, even I was scared looking at the code, I won’t hide it, but what was important was to experiment with ATDD to solve a specific problem, and that has been achieved.
Key points:
- ATDD helps you think about the user: first define the expected behavior, then implement the solution.
- Acceptance tests and unit tests complement each other: one validates the experience, the other the internal logic.
- A single source of truth in state prevents confusion and hard-to-detect bugs.
- Practicing with real projects (even if they’re old or “ugly”) is the best way to learn.
- The code doesn’t have to be perfect to apply best practices and learn something new.
Did you find this article useful? Do you have any similar experience with ATDD or TDD you want to share? I’d love to hear from you!
See you around đź«¶
Aplicando ATDD en una app de trivial de cine 🎥 ¿Te vienes? dianait.blog/blog/aplican...
— Diana Hernández (@dianait.dev) 19 July 2025 at 10:45
[image or embed]