Once you have learned how to write your own custom functions for your repositories, your next step is to keep your code covered by unit-tests.
Any custom function that you write for your repositories can be tested using three kind of tests. Learning how to write these tests may be tough, but once again, once you've climbed the steep hill, this process becomes a piece of cake.
When you write a new function in your repository, you should implement these three kind of tests:
- Call To Backend:
- The call to the backend sends the proper path, HTTP method and parameters.
- Success Response Scenarios:
- Upon any successful response, your entities are parsed properly.
- Failure Response Scenarios:
- Upon any failure scenario, a proper error is returned.
Notice that the first item always involves one test, whereas the second one and the third one may involve more than one test each, depending on how many successful and failure scenarios your function expects.
Taking the example from the Appendix A: Write your own custom functions, let's add unit-tests to this custom function to cover all the paths.
This is the custom function we want to test:
extension PostsRepository {
func read(userId: String) -> Future<[EntityType], JaymeError> {
let path = "\(users)/\(userId)/\(self.name)"
return self.backend.future(path: path, method: .GET)
.andThen { DataParser().dictionaries(from: $0.0) }
.andThen { EntityParser().entities(from: $0) }
}
}
First, let's write a test that proves that the proper path, HTTP method and parameters are sent to the backend.
In order to do so, you'll need a mock object to simulate a URLSessionBackend
. You will need two things from this fake backend for all of your tests:
- This fake backend can be asserted to analyze what arguments have been passed to its
future(path:method:parameters:)
function, in case it's called. - This fake backend doesn't connect to an actual server, but instead returns local fake results that we can configure easily.
We'll focus on the first item for this call to backend kind of test.
Good news is that these tools were already written when creating the default Creatable
, Readable
, Updatable
and Deletable
functions' tests. So, you can actually grab the fake backend from here.
If you analyze the TestingBackend
code, you can realize how it works. This class overrides the future(path:method:parameters:)
function so that:
- Instead of connecting to an actual server, it stores the passed in arguments in instance variables.
- Instead of waiting for a completion closure from a
URLSessionTask
, it uses the completion block that you set in thecompletion
variable, which can be fired whenever you want.
Then, what you need to do is setup your repository (class under test) to use a TestingBackend
instead of a URLSessionBackend
.
If you go to the PostsRepository
definition, you have to replace this:
class PostsRepository: Readable {
typealias EntityType = Post
let backend = URLSessionBackend.myAppBackend()
let name = "posts"
}
with this:
class PostsRepository: Readable {
typealias EntityType = Post
let backend: URLSessionBackend
let name = "posts"
init(backend: URLSessionBackend = .myAppBackend()) {
self.backend = backend
}
}
This enables dependency injection for the backend
variable. Now, you can setup a custom backend if you specify it in the PostsRepository
intializer. Notice that if you don't pass in a backend, by default it uses .myAppBackend()
, so you don't need to modify your source code.
Now, let's write the first test for our read(userId:)
function:
func testReadWithUserIdCallToBackend() {
let backend = TestingBackend()
let repository = PostsRepository(backend: backend)
let _ = repository.read(userId: "123")
XCTAssertEqual(backend.path, "users/123/posts")
XCTAssertEqual(backend.method, .GET)
XCTAssertNil(backend.parameters)
}
Nice. This test uses a TestingBackend
to ensure that all the arguments sent to the future(path:method:parameters:)
function are correct.
The next test to prepare is one that ensures that, upon a successful response, the method returns a proper Future
including a .success
result that contains the entities parsed properly.
This test is a bit more complex than the previous one. First, we have to simulate a successful response which includes a JSON containing the entities in the format that we expect they would arrive from the server. In order to simulate this response, we create a completion closure that we then pass in to the TestingBackend
instance.
let simulatedCompletion = { completion in
let json = [["id": "1", "content": "hello world"],
["id": "2", "content": "another post"]]
let data = try! JSONSerialization.data(withJSONObject: json, options: .prettyPrinted)
completion(.success((data, nil))) // (!)
}
let backend = TestingBackend()
backend.completion = simulatedCompletion
(!)
notice that in this line, the completion closure is called, sending a.success
result case, that includes the(Data?, PageInfo?)
tuple object that the backend works with. In this case, we send the data we've just created from our simulated JSON, and anil
pagination information object, as we don't care about pagination here.
Now it's time to complete our test. Since this fake completion closure we just created is asynchronous, we have to work with expectations from XCTest.
This is the complete test we need:
func testReadWithUserIdSuccessCallback() {
self.continueAfterFailure = false // (!)
let backend = TestingBackend()
backend.completion = { completion in
let json = [["id": "1", "content": "hello world"],
["id": "2", "content": "another post"]]
let data = try! JSONSerialization.data(withJSONObject: json, options: .prettyPrinted)
completion(.success((data, nil)))
}
let expectation = self.expectation(description: "Expected 2 posts to be parsed properly")
let repository = PostsRepository(backend: backend)
let future = repository.read(userId: "_")
future.start() { result in
guard case .success(let posts) = result
else { XCTFail(); return }
XCTAssertEqual(posts.count, 2) // (!)
XCTAssertEqual(posts[0].id, "1")
XCTAssertEqual(posts[0].content, "hello world")
XCTAssertEqual(posts[1].id, "2")
XCTAssertEqual(posts[1].content, "another post")
expectation.fulfill()
}
self.waitForExpectations(timeout: 3) { error in
if let _ = error { XCTFail() }
}
}
(!)
Notice that by setting.continueAfterFailure
tofalse
, we can avoid index out of bounds crashes and make the test fail gracefully if the array doesn't contain the expected number of items.
There's another successful scenario that can be tested, which is what happens if there are no posts for that user. This alternative will end up in something like:
func testReadWithUserIdSuccessCallbackNoPosts() {
self.continueAfterFailure = false
let backend = TestingBackend()
backend.completion = { completion in
let json = []
let data = try! JSONSerialization.data(withJSONObject: json, options: .prettyPrinted)
completion(.success((data, nil)))
}
let expectation = self.expectation(description: "Expected an empty array")
let repository = PostsRepository(backend: backend)
let future = repository.read(userId: "_")
future.start() { result in
guard case .success(let posts) = result
else { XCTFail(); return }
XCTAssertEqual(posts.count, 0)
expectation.fulfill()
}
self.waitForExpectations(timeout: 3) { error in
if let _ = error { XCTFail() }
}
}
Last cases to cover are those where a .failure
result is expected. There are several scenarios that can lead to that:
- The
user_id
doesn't exist, the server returns a404
error. - Any server error
- Bad responses from the server
By using the same simulation logic as before, we can quickly build these three tests.
The user_id
doesn't exist, the server returns a 404
error. The backend is returning a JaymeError.notFound
error, we expect our function to return a .failure
result case, with a JaymeError.notFound
error:
func testReadWithUserIdFailureNotFoundCallback() {
self.backend.completion = { completion in
let error = JaymeError.notFound
completion(.failure(error))
}
let expectation = self.expectation(description: "Expected JaymeError.notFound")
let future = self.repository.read(userId: "_")
future.start() { result in
guard
case .failure(let error) = result,
case .notFound = error
else { XCTFail(); return }
expectation.fulfill()
}
self.waitForExpectations(timeout: 3) { error in
if let _ = error { XCTFail() }
}
}
Notice that you don't need to test how the
URLSessionBackend
parses the404
status code to aJaymeError.notFound
. This test is already implemented in URLSessionBackendTests.
The server sends a response that cannot be interpreted by the client. The backend is sending .success
with corrupted data, we expect our function to return a .failure
result case, with a JaymeError.badResponse
error:
func testReadWithUserIdFailureBadResponseCallback() {
self.backend.completion = { completion in
let corruptedData = Data()
completion(.success((corruptedData, nil)))
}
let expectation = self.expectation(description: "Expected to get a JaymeError.badResponse")
let future = self.repository.read(userId: "_")
future.start() { result in
guard
case .failure(let error) = result,
case .badResponse = error
else { XCTFail(); return }
expectation.fulfill()
}
self.waitForExpectations(timeout: 3) { error in
if let _ = error { XCTFail() }
}
}
The server returns a server error. The backend is sending .failure
with JaymeError.serverError
, we expect our function to also return a .failure
result case, with a JaymeError.serverError
, that matches the same statusCode
that came from the backend:
func testReadWithUserIdFailureServerErrorCallback() {
self.backend.completion = { completion in
let error = JaymeError.serverError(statusCode: 500)
completion(.failure(error))
}
let expectation = self.expectation(description: "Expected JaymeError.notFound")
let future = self.repository.read(userId: "_")
future.start() { result in
guard
case .failure(let error) = result,
case .serverError(let statusCode) = error
else { XCTFail(); return }
XCTAssertEqual(statusCode, 500)
expectation.fulfill()
}
self.waitForExpectations(timeout: 3) { error in
if let _ = error { XCTFail() }
}
}
Awesome. Failure scenarios have been covered.
Now, you should be able to unit-test custom functions in your repositories and have your business logic covered.
It's recommended that you take a look at Jayme's test classes for further reference. Every default CRUD function has its tests there. Also, you may find yourself having to write your own custom backend implementations, or parsers, or whatnot. Take a look at those test harnesses to understand how these Jayme classes were unit-tested.