Testing nested asynchronous call with XCTest in Swift — Part 1: Check if methods are called(easy…
Testing is important, but how to do it properly? Here an easy way to test asynchronous calls using a spy and why counting is not enough

Testing asynchronous methods with spy class in Swift — Part 1: Check if methods are called(easy but not so good)
Boring Introduction
Writing tests should be a common practice, but it can be really hard and a pain sometimes. Sometimes it’s so frustrating that we just “make it pass”, without worrying if it’s a really useful test to protect our production code or just an “I test my code” test.
But of course, this approach is not the right one: it leads to a bunch of unuseful tests that someone has to maintain for no reason.
In these articles, I want to show how I improved my tests of asynchronous methods using a spy class and I hope it can help someone else. Probably is not perfection yet, but I think it’s a start.
In this first part, I’ll explain the easiest how to check if asynchronous methods are called, and why this is not enough.
Getting Started
Final project link: https://github.com/Meox92/SignupApp.git (remember to checkout the branch part1_test_method_count)
In this project, we are gonna signup a new user, and this user can have an invitation-code shared by a friend.
The target is macOS so we don’t need to run a simulator and tests will run faster.
So, let’s get started!
This is the User model
struct User {
let email: String
let invitationCode: String?
}
A protocol encapsulates the methods we need to create a user with a referral code. I’ve chosen two different methods to follow the principle of Single Responsibility and because it makes easier to make changes if in the future we decide to eliminate referrals in our application.
Both methods return a Swift Result enum with the user or an error.
protocol UserServices {
func signup(with mail: String, password: String, completion: (Result<User, Error>) -> Void)
func validateCode(code: String, user: User, completion: @escaping(Result<User, Error>) -> Void)
}
And this is the code we are gonna test, the system under test(SUT). To keep things easy I handled only the success cases.
[embed]
- We inject UserServices through constructor injection. Remember that UserServices is not a concrete class but an abstraction(a protocol in this case) so we can easily use another UserServices(a spy) in our tests.
- First, we create a user, then we associate a referral code to it.
Let’s test
So, let’s create a test class in xCode.
In this article, we are gonna simply check if the async methods signup and validateCode are called. I think is the easiest way to test methods, and at the end of this article, I’ll explain why it is not enough and the issues of this approach.
Usually, I create a factory method(something like func makeSUT() -> UserUseCase) to protect my test against changes, so if in the future UserUseCase needs more dependencies, I need only to change the makeSUT function and my tests don’t break.
Before creating an instance of UserUseCase, we need to create an implementation of UserServices to inject into it, a UserServicesSpy that mocks the behavior of real UserServices class.
Let’s start with the easiest implementation, just an inner private class that conforms to UserServices protocol
private class UserServicesSpy: UserServices {
func signup(with mail: String, password: String, completion: (Result<User, Error>) -> Void) {
}
func validateCode(code: String, user: User, completion: @escaping(Result<User, Error>) -> Void) {
}
}
The test class looks something like this.
[embed]
The function makeSUT returns a tuple of UserUseCase and UserServices(we are gonna use it in the tests).
In the first test, we are gonna check that the signup method is called. We create a signupCalledCount variable in the UserServicesSpy and simply increase it every time that the signup method is called.
private class UserServicesSpy: UserServices {
var signupCalledCount: Int = 0
func signup(with mail: String, password: String, completion: (Result<User, Error>) -> Void) {
signupCalledCount += 1
completion(.success(anyUser()))
}
func validateCode(code: String, user: User, completion: @escaping (Result<User, Error>) -> Void) { }
private func anyUser() -> User {
return User(email: "a-valid-email@gmail.com", invitationCode: "a-referral-code")
}
}
In the completion of the signup user, we just pass a success enum with a generic user instance, we don’t care about failure case or the value of the user for now.
In the first test, we call the signupWithReferralCode and check that signupCalledCount is increased by one
func test_requestSignup_onSignupWithReferral() {
// Given
let (sut, userServices) = makeSUT()
// When
sut.signupWithReferralCode(email: "a-valid-email@email.com", password: "123456A", referral: "a-referral-code") { _ in }
// Then
XCTAssertEqual(userServices.signupCalledCount, 1)
}
The signup method is tested, and we are gonna test the call of validateCode in the same way
private class UserServicesSpy: UserServices {
var signupCalledCount: Int = 0
var validateCodeCalledcount: Int = 0
func signup(with mail: String, password: String, completion: (Result<User, Error>) -> Void) {
signupCalledCount += 1
completion(.success(anyUser()))
}
func validateCode(code: String, user: User, completion: @escaping (Result<User, Error>) -> Void) {
validateCodeCalledcount += 1
completion(.success(anyUser()))
}
private func anyUser() -> User {
return User(email: "a-valid-email@gmail.com", invitationCode: "a-referral-code")
}
}
The test for validateCode is also really similar to the signup, it just has an additional expectation
func
test_requestValidateCodeAfterUserCreation_onSignupWithReferral() {
// Given
let (sut, userServices) = makeSUT()
let exp = expectation(description: "Wait for user creation")
// When
sut.signupWithReferralCode(email: "a-valid-email@email.com", password: "123456A", referral: "a-referral-code") { _ in
exp.fulfill()
}
wait(for: [exp], timeout: 3.0)
// Then
XCTAssertEqual(userServices.validateCodeCalledcount, 1)
}
Run the test and yeah, it’s passing!
STOP! These tests are wrong!
What?! Did you just spend precious time reading something wrong? And did I spend precious time writing something wrong?
Not totally, the testing method described in this article is an easy one(I think the easiest to start with testing code) and is working, but has a lot of room for improvement. You cannot reach the moon if you don’t take the first step :)
Let’s see what is not working in these tests
- We are just checking that the methods are called, but not the order. Let’s say someone messes up with the code and reverses the order of the methods. So first, try to add a referral code to a user, then create the user. Cleary wrong, but you never know what a junior developer can do(I’m a junior developer, so I know that I could do something like this).
[embed]The tests won’t help us in this case
- We are not checking the values passed in the completion blocks. What if we never pass the updated user with referral code but keep passing the just-signup-user to the completion block? Again, the tests don’t help us and pass, deceiving ourselves that everything is okay.
[embed]In this scenario, we never pass the right user to the completion block
3. We just handle success cases. We force our UserServicesSpy to always return success. I think that failure cases are important as successful ones, or even more. Having malfunction in an application is a bad experience, but having a malfunction and no feedback is a worse experience.
In the next article, I’ll explain how I’ve dealt with all these issues and how I improved my tests.
Final project link: https://github.com/Meox92/SignupApp.git (remember to checkout the branch part1_test_method_count)
메타데이터
- post_id
- a2b808d8be77
- slug
- testing-nested-asynchronous-call-with-xctest-in-swift-part-1-check-if-methods-are-called-easy-a2b808d8be77
- url
- https://medium.com/@meox92/testing-nested-asynchronous-call-with-xctest-in-swift-part-1-check-if-methods-are-called-easy-a2b808d8be77
- canonical_url
- https://medium.com/@meox92/testing-nested-asynchronous-call-with-xctest-in-swift-part-1-check-if-methods-are-called-easy-a2b808d8be77
- author_url
- https://medium.com/@meox92
- status
- ok
- fetched_at
- 2026-07-29 06:21:49