Writing a good and clean code is one of the most important things in development. Testing is also that much important. Writing good tests prevents your app from going to production with some nasty bug. We should test as many layers as we can, so we can identify where the problem lies if there are some. As promised, we will continue with the last chapter and explain how to write a protocol-oriented testable code. At the end of this article, ideally, we will have an app that is testable on a network layer.
There are not many apps that don’t communicate with a server, so the network layer is very important to be implemented as flexible as possible. Let’s make few layers properly, so they can be testable. We will start by defining our NetworkService.
NOTE: In the next article we will create an isolated, business logic-free ApiClient and EndpointBuilder that are also testable.
protocol NetworkService {
func getAllBooks(params: BookParams) -> AnyPublisher<[Book], AppError>
}
In our network service, we will create a request and fire it. As we want to divide the responsibility, we will also create EndpointBuilder along with ApiClient(in charge of sending a real request).
final class NetworkServiceImplementation: NetworkService {
private let apiClient: ApiRequest
private let endpointBuilder: EndpointBuilder
init(apiClient: ApiRequest,
endpointBuilder: EndpointBuilder) {
self.apiClient = apiClient
self.endpointBuilder = endpointBuilder
}
func getAllBooks(params: BookParams) -> AnyPublisher<[Book], AppError> {
createRequest(endpointBuilder.request(for: .books,
method: .post,
isAuthorized: true,
parameters: params))
}
func getBook() -> AnyPublisher<Book, AppError> {
createRequest(endpointBuilder.request(for: .bookDetails,
method: .get,
isAuthorized: false))
}
}
private extension NetworkServiceImplementation {
func createRequest<T: Decodable>(_ request: URLRequest) -> AnyPublisher<T, AppError> {
apiClient.make(request)
.tryMap { data -> T in
return try JSONDecoder().decode(T.self, from: data)
}
.eraseToAnyAppError()
}
}
Now, let’s go to our ListView and implement the structure that will provide us Books list from the server. First, let’s create a ViewModel that will have a business logic of downloading the list and applying any additional action before presenting it. We will have one published property that will propagate the list of the books and a method to get books that will be called when this ViewModel is inited.
final class BooksListViewModel: ObservableObject {
@Published var books: [Book] = [Book]()
private let service: NetworkService
private var cancellables: Set<AnyCancellable> = []
var showEmptyBooksView: Bool {
return books.count < 1
}
init(service: NetworkService) {
self.service = service
getAllBooks()
}
func getAllBooks() {
service.getAllBooks(params: BookParams(id: "TEST"))
.receive(on: DispatchQueue.main)
.sink { status in
print(status)
} receiveValue: { [weak self] books in
self?.books = books
}
.store(in: &cancellables)
}
}
struct BookParams: Encodable {
let id: String
}
Afterward, the implementation on ListView is not changed too much. We will also add some empty view state handlers, so users will be informed if the books list is empty.
struct BooksList: View {
@ObservedObject var viewModel: BooksListViewModel
init(viewModel: BooksListViewModel) {
self.viewModel = viewModel
}
var body: some View {
NavigationView {
renderMainView
.navigationBarTitle("Books list", displayMode: .large)
}
.navigationViewStyle(StackNavigationViewStyle())
}
var renderMainView: some View {
Group {
if viewModel.showEmptyBooksView {
renderEmptyBooksView
} else {
renderBooksListView
}
}
}
var renderEmptyBooksView: some View {
EmptyBooksView()
}
var renderBooksListView: some View {
List {
ForEach(viewModel.books) { book in
RowView(book: book)
}
}
}
}
struct EmptyBooksView: View {
var body: some View {
Text("Books shelf is empty ?")
}
}
At this point, ideally, we have an app that will send a request, get raw data, decode to a list of books, and present it. Everything should work, but we cannot test all cases just by running the app. Let’s write some tests. We will start with our ViewModel. In this case, we want to test if our flag for showing EmptyBooksView works properly. We will not test our asynchronous code here as we will do it in the network layer.
var viewModelSut: BooksListViewModel {
BooksListViewModel(service: mockNetworkService)
}
var mockNetworkService: NetworkService {
MockNetworkService()
}
func test_viewModelStates() {
let sut = viewModelSut
sut.books = BooksMock.getBooks()
XCTAssertFalse(sut.showEmptyBooksView)
sut.books = []
XCTAssertTrue(sut.showEmptyBooksView)
}
After testing our viewModel states, let’s test a real network call. For this, we need to mock our network service. We will also introduce shouldFail boolean so we can easily manipulate which type of response we want to receive.
final class MockNetworkService: NetworkService {
var shouldFail = false
func getAllBooks(params: BookParams) -> AnyPublisher<[Book], AppError> {
if shouldFail {
return Fail(error: AppError.generic)
.eraseToAnyAppError()
} else {
return Just(BooksMock.getBooks())
.eraseToAnyAppError()
}
}
func getBook() -> AnyPublisher<Book, AppError> {
return Just(BooksMock.getBooks()[0])
.eraseToAnyAppError()
}
}
In tests it should looks something like this:
func test_getAllBooksSuccess() {
let sut = mockNetworkService
let expectation = self.expectation(description: "books")
sut.getAllBooks(params: .init(id: "Test"))
.sink { status in
switch status {
case .failure:
XCTFail()
case .finished:
XCTAssertNotNil(status)
}
} receiveValue: { books in
XCTAssertEqual(books.count, 3)
expectation.fulfill()
}
.store(in: &cancellables)
wait(for: [expectation], timeout: 1)
}
func test_getAllBooksFail() {
let sut = mockNetworkService
sut.shouldFail = true
let expectation = self.expectation(description: "booksFail")
sut.getAllBooks(params: .init(id: "Fail"))
.sink { status in
switch status {
case .failure:
expectation.fulfill()
case .finished:
XCTFail()
}
} receiveValue: { books in
XCTAssertNil(books)
}
.store(in: &cancellables)
wait(for: [expectation], timeout: 1)
}
So, that’s it. We have a network layer that we can test what happens if our request fails or returns success.
