Привет, читатель!
Не секрет, что тестирование является неотъемлемой частью любой разработки. В предыдущих статьях мы разобрали основную структуру архитектуры Clean Swift и теперь пора научиться покрывать ее Unit тестами. За основу мы возьмем проект из статьи о Worker’ах и разберем основные моменты.
Теория
Благодаря инжектированию зависимостей и протоколо-ориентированности, все компоненты сцены в Clean Swift независимы друг от друга и могут тестироваться отдельно. Как пример, Interactor зависим от Presenter’а и Worker’а, но эти зависимости опциональны и основаны на протоколах. Таким образом, Interactor может выполнять свою работу (хоть и неполноценную) без Presenter’a и Worker’a, а так же мы можем подменить их на другие объекты, подписанные под их протоколы.
Так как мы хотим тестировать каждый компонент отдельно, нам нужно заменить зависимости на псевдо-компоненты. В этом нам помогут шпионы (Spy). Spy — это объекты тестирования, реализующие протоколы, которые мы хотим инжектировать, и отслеживающие вызовы методов в них. Другими словами, мы создаем Spy для Presenter’a и Worker’a, а затем инжектируем их в Interactor для отслеживания вызова методов.
Справедливости ради, дополню, что так же есть объекты тестирования (Test Doubles) Dummy, Fake, Stub и Mock. Но в рамках этой статьи, мы не будем их затрагивать. Подробнее можно почитать здесь — TestDoubles
Практика
Закончили со словами, переходим к делу. Для экономии вашего времени, мы будем рассматривать абстрактный код, не вдаваясь в подробности реализации каждого метода. Детально с кодом приложения можно ознакомиться здесь: CleanSwiftTests
Для каждого компонента сцены мы создаем файл с тестами и тестовые двойники для зависимостей компонента (Test Doubles).
Пример такой структуры:
Структура каждого файла с тестами (для компонентов) выглядит одинаково и придерживается, примерно, такой последовательности написания:
- Объявляем переменную с SUT (объект, который будем тестировать) и переменные с его основными зависимостями
- Производим инициализацию SUT и его зависимостей в setUp(), а затем очищаем их в tearDown()
- Методы тестирования
Как мы разбирали в теории, каждый компонент сцены может тестироваться отдельно. Мы можем инжектировать в его зависимости тестовые дубли (Spy) и тем самым отслеживать работу методов нашего SUT. Рассмотрим детально процесс написания тестов на примере Interactor’a сцены Home.
HomeInteractor зависим от двух объектов — Presenter и Worker. Обе переменные, в классе, имеют тип протоколов. Это значит, что мы можем создать тестовые дубли, подписанные под протоколы HomePresentationLogic и HomeWorkingLogic, а затем инжектировать их в HomeInteractor.
final class HomeInteractor: HomeBusinessLogic, HomeDataStore {
// MARK: - Public Properties
var presenter: HomePresentationLogic?
lazy var worker: HomeWorkingLogic = HomeWorker()
var users: [User] = []
var selectedUser: User?
// MARK: - HomeBusinessLogic
func fetchUsers(_ request: HomeModels.FetchUsers.Request) {
// ...
}
func selectUser(_ request: HomeModels.SelectUser.Request) {
// ...
}
Тестировать мы будем два метода:
- fetchUsers(:). Отвечает за получение списка пользователей по API. Запрос к API отправляется с помощью Worker’a.
- selectUser(:). Отвечает за выбор активного пользователя (selectedUser) из списка загруженных пользователей (users).
Для начала написания тестов Interactor’a, мы должны создать шпионов, которые будут отслеживать вызов методов в HomePresentationLogic и HomeWorkingLogic. Для этого создаем класс HomePresentationLogicSpy в директории 'CleanSwiftTestsTests/Stores/Home/TestDoubles/Spies', подписываем под протокол HomePresentationLogic и реализуем метод этого протокола.
final class HomePresentationLogicSpy: HomePresentationLogic {
// MARK: - Public Properties
private(set) var isCalledPresentFetchedUsers = false
// MARK: - Public Methods
func presentFetchedUsers(_ response: HomeModels.FetchUsers.Response) {
isCalledPresentFetchedUsers = true
}
}
Здесь все предельно прозрачно. Если метод presentFetchedUsers (протокола HomePresentationLogic) был вызван, мы устанавливаем значение переменной isCalledPresentFetchedUsers на true. Тем самым, мы можем отследить был ли вызван данный метод в ходе тестирования Interactor’a.
По такому же принципу создаем HomeWorkingLogicSpy. Одно отличие, мы вызываем completion, т.к. часть кода в Interactor’е будет обернута в замыкание этого метода. Методы HomeWorkingLogic занимаются сетевыми запросами. Нам необходимо избежать реальных запросов в сеть во время тестирования. Для этого мы и заменяем его тестовым дублем, который отслеживает вызов методов и возвращает шаблонные данные, но при этом не совершает запросов в сеть.
final class HomeWorkingLogicSpy: HomeWorkingLogic {
// MARK: - Public Properties
private(set) var isCalledFetchUsers = false
let users: [User] = [
User(id: 1, name: "Ivan", username: "ivan91"),
User(id: 2, name: "Igor", username: "igor_test")
]
// MARK: - Public Methods
func fetchUsers(_ completion: @escaping ([User]?) -> Void) {
isCalledFetchUsers = true
completion(users)
}
}
Далее создаем класс HomeInteractorTests, которым будем тестировать HomeInteractor.
final class HomeInteractorTests: XCTestCase {
// MARK: - Private Properties
private var sut: HomeInteractor!
private var worker: HomeWorkingLogicSpy!
private var presenter: HomePresentationLogicSpy!
// MARK: - Lifecycle
override func setUp() {
super.setUp()
let homeInteractor = HomeInteractor()
let homeWorker = HomeWorkingLogicSpy()
let homePresenter = HomePresentationLogicSpy()
homeInteractor.worker = homeWorker
homeInteractor.presenter = homePresenter
sut = homeInteractor
worker = homeWorker
presenter = homePresenter
}
override func tearDown() {
sut = nil
worker = nil
presenter = nil
super.tearDown()
}
// MARK: - Public Methods
func testFetchUsers() {
// ...
}
func testSelectUser() {
// ...
}
}
Мы указываем три основные переменные — sut, worker и presenter.
В setUp() инициализируем необходимые объекты, инжектируем зависимости в Interactor и присваиваем объекты в переменные класса.
В tearDown() мы очищаем переменные классы для чистоты эксперимента.
Метод setUp() вызывается перед стартом метода тестирования, например testFetchUsers(), а tearDown(), когда работа этого метода была завершена. Тем самым, мы пересоздаем объект тестирования (sut) перед каждым запуском тестового метода.
Далее идут сами методы тестирования. Структура делится на 3 основных логических блока — создание необходимых объектов, запуск трестируемого метода в SUT и проверка результатов. В примере ниже, мы создаем request (в нашем случае он без параметров), запускаем метод fetchUsers(:) Interactor’a, а затем проверяем, были ли вызваны необходимые методы в HomeWorkingLogicSpy и HomePresentationLogicSpy. Так же проверяем, сохранил ли Interactor тестовые данные, полученные от Worker’a, в свой DataStore.
func testFetchUsers() {
let request = HomeModels.FetchUsers.Request()
sut.fetchUsers(request)
XCTAssertTrue(worker.isCalledFetchUsers, "Not started worker.fetchUsers(:)")
XCTAssertTrue(presenter.isCalledPresentFetchedUsers, "Not started presenter.presentFetchedUsers(:)")
XCTAssertEqual(sut.users.count, worker.users.count)
}
Выбор пользователя будем тестировать по похожей структуре. Мы объявляем переменные expectationId и expectationName, по которым мы будем сравнивать результат выбора пользователя. Переменная users хранит тестовый список пользователей, который мы присвоим в Interactor. Т.к. методы тестирования вызываются независимо друг от друга, а в tearDown() мы обнуляем данные, то список пользователей Interactor’a пуст и нам нужно его чем-то заполнить. А далее мы проверяем, был ли присвоен пользователь в DataStore Interactor’a, после вызова sut.selectUser(:), и нужный ли это пользователь.
func testSelectUser() {
let expectationId = 2
let expectationName = "Vasya"
let users = [
User(id: 1, name: "Ivan", username: "ivan"),
User(id: 2, name: "Vasya", username: "vasya91"),
User(id: 3, name: "Maria", username: "maria_love")
]
let request = HomeModels.SelectUser.Request(index: 1)
sut.users = users
sut.selectUser(request)
XCTAssertNotNil(sut.selectedUser, "User not selected")
XCTAssertEqual(sut.selectedUser?.id, expectationId)
XCTAssertEqual(sut.selectedUser?.name, expectationName)
}
Тестирование Presenter’a и ViewController’a происходит по такому же принципу, с минимальными отличиями. Одно из отличий в том, что для тестирования ViewController’a потребуется создать UIWindow и получить контроллер со Storyboard’a в setUp(), а так же делать Spy объекты на таблицы и коллекции. Но эти нюансы варьируются от потребностей.
Для полноты картины, рекомендую ознакомиться с проектом по ссылке в конце статьи.
Заключение
Мы разобрали основные принципы тестирования приложений на архитектуре Clean Swift. Она не имеем принципиально сильных отличий от тестирования проектов на других архитектурах, все такие же тестовые дубли, инжектирование и протоколы. Главное не забывать, что у каждого VIP цикла должна быть одна (и только одна!) ответственность. Это сделает код чище, а тесты — очевидней.
Ссылка на проект: CleanSwiftTests
Помощь в написании статьи: Bastien
Серия статей
- Общее представление об архитектуре Clean Swift
- Router и Data Passing в архитектуре Clean Swift
- Workers в архитектуре Clean Swift
- Unit тестирование в архитектуре Clean Swift (вы здесь)
- Пример простого интернет-магазина на архитектуре Clean Swift