Как стать автором
Обновить

Composable Architecture — свежий взгляд на архитектуру приложения. Тесты

Время на прочтение 7 мин
Количество просмотров 3.1K

Сбалансированная архитектура мобильного приложения продлевает жизнь проекту и разработчикам.


В прошлой серии


Часть 1 — основные компоненты архитектуры и как работает Composable Architecture


Тестируемый код


В предыдущем выпуске был разработан каркас приложения список покупок на Composable Architecture. Перед тем как продолжить наращивать функционал необходимо сохраниться — покрыть код тестами. В этой статье рассмотрим два вида тестов: unit тесты на систему и snapshot тесты на UI.


Что мы имеем?


Еще раз взглянем на текущее решение:


  • состояние экрана описывается списком продуктов;
  • два вида событий: изменить продукт по индексу и добавить новый;
  • механизм, обрабатывающий действия и меняющий состояние системы — яркий претендент для написания тестов.

struct ShoppingListState: Equatable {
    var products: [Product] = []
}

enum ShoppingListAction {
    case productAction(Int, ProductAction)
    case addProduct
}

let shoppingListReducer: Reducer<ShoppingListState, ShoppingListAction, ShoppingListEnviroment> = .combine(
    productReducer.forEach(
        state: \.products,
        action: /ShoppingListAction.productAction,
        environment: { _ in ProductEnviroment() }
    ),
    Reducer { state, action, env in
        switch action {
        case .addProduct:
            state.products.insert(
                Product(id: UUID(), name: "", isInBox: false),
                at: 0
            )
            return .none
        case .productAction:
            return .none
        }
    }
)

Типы тестов


Как понять что архитектура не очень? Легко, если вы не можете покрыть ее на 100% тестами (Vladislav Zhukov)

Не все архитектурные паттерны четко регламентируют подходы к тестированию. Рассмотрим как эту задачу решает Composable Arhitecutre.


Unit тесты


Одна из причин полюбить Composable Arhitecutre является подход к написанию unit тестов.


image alt

Тестирование основного механизма системы — recuder'а — происходит с помощью построения цепочки шагов: send(Action) и receive(Action). На каждом этапе проверяем, что состояние системы изменилось должным образом.


Send(Action) позволяет имитировать действий пользователя.


Receive(Action) говорит о том, что на предыдущем шаге выполнился эффект и вернул результат — action.


В конце теста или по ходу цепочки в блоке .do {} проверяем обращения к сервисам.


Первый наш тест посвящен операции добавления продукта.


func testAddProduct() {
    // Создаем тестовый стор
    let store = TestStore(
        initialState: ShoppingListState(
            products: []
        ),
        reducer: shoppingListReducer,
        environment: ShoppingListEnviroment()
    )
    // описываем ожидаемое поведение системы
    store.assert(
        // создаем событие добавление продукта
        .send(.addProduct) { state in
            // описываем ожидаемое состояние системы
            state.products = [
                Product(
                    id: UUID(),
                    name: "",
                    isInBox: false
                )
            ]
        }
    )
}

Первое на что следует обратить внимание, что тестирование системы происходит независимо от слоя представления.


image alt

Запускаем тест и ловим фейл


Достаточно информативное сообщение об ошибке говорит нам о несовпадении присвоенного идентификатора продукта с ожидаемым:


image


Для того, чтобы разобраться в чем дело, введем понятие чистая функция.


Reducer — чистая функция


Что же такое чистая функция?


«Чистые» функции — это любые функции, исходные данные которых получены исключительно из их входных данных и не вызывают побочных эффектов в приложении.


В действительности, внутри нашей функции генерируется UUID в качестве идентификатора продукта. Такое поведение называется побочным эффектом, а наша функция становится "грязной".


Чтобы это исправить необходимо генерировать UUID через сервис. В Composable Architecture сервисы представлены объектом окружения (Environment).


Добавим в наш ShoppingListEnviroment сервис (функцию) генерации UUID.


struct ShoppingListEnviroment {
    var uuidGenerator: () -> UUID
}

И используем ее при создании продукта:


Reducer { state, action, env in
    switch action {
    case .addProduct:
        state.products.insert(
            Product(
                id: env.uuidGenerator(),
                name: "",
                isInBox: false
            ),
            at: 0
        )
        return .none
    ...
    }
}

В результате получаем чистую функцию, которую можно тестировать. Возвращаясь к нашему тесту получаем следующее:


func testAddProduct() {
    let store = TestStore(
        initialState: ShoppingListState(),
        reducer: shoppingListReducer,
        // Создаем окружение
        environment: ShoppingListEnviroment(
            // инжектим сервис генерации мокового UUID
            uuidGenerator: { UUID(uuidString: "00000000-0000-0000-0000-000000000000")! }
        )
    )
    store.assert(
        // Имитируем нажатие на кнопку "добавить продукт"
        .send(.addProduct) { newState in
            // Описываем ожидаемое изменение состояния системы
            newState.products = [
                Product(
                    // продукту установился определенный в сервисе UUID
                    id: UUID(uuidString: "00000000-0000-0000-0000-000000000000")!,
                    name: "",
                    isInBox: false
                )
            ]
        }
    )
}

Чтобы посмотреть на более интересный тест, добавим кэширование списка продуктов из следующего выпуска. Для этого добавим еще два сервиса: saveProducts и loadProducts:


struct ShoppingListEnviroment {
    var uuidGenerator: () -> UUID
    var save: ([Product]) -> Effect<Never, Never>
    var load: () -> Effect<[Product], Never>
}

Предполагая, что операции загрузки и сохранения могут быть асинхронные, они возвращают Effect. Effect — не что иное как Publisher. Более подробнее рассмотрим в следующей серии.


Пишем тест:


func testAddProduct() {
    // проверим, что сохраняется то, что нужно
    var savedProducts: [Product] = []
    // убедимся, что количество сохранений совпадает с ожидаемым
    var numberOfSaves = 0
    // создаем тестовый стор
    let store = TestStore(
        initialState: ShoppingListState(products: []),
        reducer: shoppingListReducer,
        environment: ShoppingListEnviroment(
            uuidGenerator: { .mock },
            // функция сохранения принимает массив продуктов
            // и возвращает эффект сохраняющий список
            saveProducts: { products in Effect.fireAndForget { savedProducts = products; numberOfSaves += 1 } },
            // функция загрузки списка
            // возвращает эффект с закэшированным списком 
            loadProducts: { Effect(value: [Product(id: .mock, name: "Milk", isInBox: false)]) }
        )
    )
    store.assert(
        // иммитируем отправку события load при показе view
        .send(.loadProducts),
        // событие load запускает эффект загрузки данных
        // который возвращает событие productsLoaded([Product])
        .receive(.productsLoaded([Product(id: .mock, name: "Milk", isInBox: false)])) {
            $0.products = [
                Product(id: .mock, name: "Milk", isInBox: false)
            ]
        },
        // добавляем новый продукт в список
        .send(.addProduct) {
            $0.products = [
                Product(id: .mock, name: "", isInBox: false),
                Product(id: .mock, name: "Milk", isInBox: false)
            ]
        },
        // ожидаем, что предыдущее действие вызывало эффект сохранения
        .receive(.saveProducts),
        // после выполнения эффекта проверяем сохраненный результат
        .do {
            XCTAssertEqual(savedProducts, [
                Product(id: .mock, name: "", isInBox: false),
                Product(id: .mock, name: "Milk", isInBox: false)
            ])
        },
        // задаем имя добавленному продукту
        .send(.productAction(0, .updateName("Banana"))) {
            $0.products = [
                Product(id: .mock, name: "Banana", isInBox: false),
                Product(id: .mock, name: "Milk", isInBox: false)
            ]
        },
        // имитируем событие сохранения в endEditing textFiled'a 
        .send(.saveProducts),
        // после выполнения эффекта проверяем сохраненный результат
        .do {
            XCTAssertEqual(savedProducts, [
                Product(id: .mock, name: "Banana", isInBox: false),
                Product(id: .mock, name: "Milk", isInBox: false)
            ])
        }
    )
    // убеждаемся, что сохранение произошло только 2 раза
    XCTAssertEqual(numberOfSaves, 2)
}

В этом блоке мы:


  • рассмотрели написание unit тестов на систему;
  • определили инструменты тестирования;
  • написали тест, имитирующий действия пользователя при добавлении нового продукта в список.

Unit-Snapshot тесты на UI


Для snapshot тестов, авторы Composable Arhitecture разработали библиотеку SnapshotTesting (также можно использовать любое другое решение).


Для текущей итерации разработки, определяем минимальное количество различных состояний экрана равное четырем:


  • пустой список;
  • список с только что добавленным продуктом;
  • список с одним не выбранным продуктом;
  • список с одним выбранным продуктом.

Composable Architecture реализует подход data-driven development, что значительно облегчает написание snapshot-тестов — конфигурация UI определяется текущим состоянием системы.


Приступим:


import XCTest
import ComposableArchitecture
// Подключаем библиотеку для снепшот тестирования
import SnapshotTesting
@testable import Composable

class ShoppingListSnapshotTests: XCTestCase {

    func testEmptyList() {
        // Создаем view
        let listView = ShoppingListView(
            // создаем систему
            store: ShoppingListStore(
                // устанавливаем состояние
                initialState: ShoppingListState(products: []),
                reducer: Reducer { _, _, _ in .none },
                environment: ShoppingListEnviroment.mock
            )
        )
        assertSnapshot(matching: listView, as: .image)
    }

    func testNewItem() {
        let listView = ShoppingListView(
            // Чтобы не создавать store каждый раз 
            // можно завести экстеншен Store.mock(state:State)
            store: .mock(state: ShoppingListState(
                products: [Product(id: .mock, name: "", isInBox: false)]
            ))
        )
        assertSnapshot(matching: listView, as: .image)
    }

    func testSingleItem() {
        let listView = ShoppingListView(
            store: .mock(state: ShoppingListState(
                products: [Product(id: .mock, name: "Milk", isInBox: false)]
            ))
        )
        assertSnapshot(matching: listView, as: .image)
    }

    func testCompleteItem() {
        let listView = ShoppingListView(
            store: .mock(state: ShoppingListState(
                products: [Product(id: .mock, name: "Milk", isInBox: true)]
            ))
        )
        assertSnapshot(matching: listView, as: .image)
    }
}

После выполнения всех тестов получаем набор эталонных значений:


image


В результате для каждого состояния системы было зафиксировано его визуальное представление.


Debug mode — вишенка на торте


Для отладки работы редьюсера есть полезный инструмент debug:


Reducer { state, action, env in
    switch action { ... }
}.debug()
// или
Reducer { state, action, env in
    switch action { ... }
}.debugActions()

Функция debug логирует в консоль каждый вызов функции редьюсера, указывая какое действие произошло и как изменилось состояние системы:


received action:
  ShoppingListAction.load
  (No state changes)

received action:
  ShoppingListAction.setupProducts(
    [
      Product(
        id: 9F047826-B431-4D20-9B80-CC65D6A1101B,
        name: "",
        isInBox: false
      ),
      Product(
        id: D9834386-75BC-4B9C-B87B-121FFFDB2F93,
        name: "Tesggggg",
        isInBox: false
      ),
      Product(
        id: D4405C13-2BB9-4CD4-A3A2-8289EAC6678C,
        name: "",
        isInBox: false
      ),
    ]
  )
  ShoppingListState(
    products: [
+     Product(
+       id: 9F047826-B431-4D20-9B80-CC65D6A1101B,
+       name: "",
+       isInBox: false
+     ),
+     Product(
+       id: D9834386-75BC-4B9C-B87B-121FFFDB2F93,
+       name: "Tesggggg",
+       isInBox: false
+     ),
+     Product(
+       id: D4405C13-2BB9-4CD4-A3A2-8289EAC6678C,
+       name: "",
+       isInBox: false
+     ),
    ]
  )

*плюсом отмечается изменения состояния системы.


Смотри в следующей серии


Часть 3 — расширяем функционал, добавляем удаление и сортировку продуктов (in progress)


Часть 4 — добавляем кэширование списка и идем в магазин (in progress)


Источники


Список продуктов Часть 2: github.com


Портал авторов подхода: pointfree.co


Исходники Composable Architecture: https://github.com/pointfreeco/swift-composable-architecture


Исходники Snaphsot testing: github.com

Теги:
Хабы:
+1
Комментарии 2
Комментарии Комментарии 2

Публикации

Истории

Работа

Swift разработчик
38 вакансий
iOS разработчик
23 вакансии

Ближайшие события

Московский туристический хакатон
Дата 23 марта – 7 апреля
Место
Москва Онлайн
Геймтон «DatsEdenSpace» от DatsTeam
Дата 5 – 6 апреля
Время 17:00 – 20:00
Место
Онлайн