Pull to refresh

Behavior-Driven Testing для iOS используя Quick и Nimble

Reading time14 min
Views6.6K
Original author: Shai Mishali
После прочтения данной статьи, вы сможете использовать Quick и Nimble в своих проектах!

Behavior-Driven Testing для iOS используя Quick и Nimble

Написание прекрасных работоспособных приложений — одно дело, но написание хороших тестов, которые подтверждают ожидаемое поведение вашего приложения, — задача намного сложнее. В этой статье мы рассмотрим один из доступных подходов тестирования приложений, behavior-driven testing, используя два чрезвычайно популярных фреймворка под названием Quick и Nimble.

Вы узнаете о behavior-driven testing: изучите что это такое, почему это чрезвычайно мощная концепция и насколько легко писать читабельные тесты с помощью фреймворков Quick и Nimble.

Вы будете писать тесты для удивительно простой и забавной игры под названием AppTacToe, в которой вы играете в игру Tic Tac Toe против компьютера, изображая iOS персонажа, играющего против злого Android игрока!

Примечание: Для лучшего понимания данной статьи предполагается что у Вас есть наличие базовых знаний темы Unit Testing и использования XCTestCase.

Несмотря на то, что вы можете продолжить прочтение данной статьи без наличия этих знаний, все же рекомендуется ознакомится со статьёй iOS Unit Testing and UI Testing Tutorial для повторения ранее изученных основ.

Начало


Лучший способ начать написание тестов — это работать над настоящим приложением, которым в вашем случае будет игра AppTacToe, представленная ранее.

Загрузите стартовый проект, который содержит все необходимое для работы, он уже содержит Quick и Nimble. После загрузки откройте AppTacToe.xcworkspace.

Откройте Main.storyboard и изучите базовую структуру приложения. Она состоит из двух экранов: Board, в котором происходит сама игра, и экран «Game Over screen», отвечающий за отображение результата игры.

image


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

image


Вы также увидите полезное логирование, которое отображается в консоли, дублируя ход игры и отображая итоговую доску, после завершения игры.

Примечание: Не беспокойтесь, если заметите незначительные ошибки во время игры; вы исправите их, вовремя работы над данной статьей!

Большая часть бизнес логики приложения содержится в одном из двух файлов:
Components/Board.swift: Этот файл обеспечивает логическую реализацию игры Tic Tac Toe. В нем нету элементов UI интерфейса, связанного с данной игрой.

ViewControllers/BoardViewController.swift: Это основной игровой экран. Он использует вышеупомянутый класс Board для игры и несет ответственность за отображение состояния игры на экране устройства и обработку взаимодействия с пользователем.

То, что вам действительно необходимо протестировать в этом случае, это бизнес логику игры, поэтому вы будете писать тесты для класса Board.

Чем же является Behavior-Driven Testing?


Приложение состоит из множества фрагментов кода. В традиционных модульных тестах вы проверяете всевозможные элементы каждой из этих частей. Вы предоставляете некоторые данные для некоторого фрагмента кода и утверждаете, что он возвращает ожидаемый результат.

Недостатком такого подхода является то, что он подчеркивает необходимость проверки внутренней работы приложения. Это означает, что вы тратите больше времени на тестирование деталей реализации, а затем на фактическую бизнес-логику, которая является реальным мясом вашего продукта!

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

Давайте познакомимся с behavior-driven тестированием!

behavior-driven testing


При behavior-driven testing (или BDT), ваши тесты основаны на user stories, которые описывают некоторые конкретные ожидаемые действия приложения. Вместо проверки деталей реализации вы фактически проверяете то, что важно больше всего: правильно ли приложение выполняет user stories?

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

Вот некоторые примеры пользовательских историй, которые вы могли бы написать как часть игры AppTacToe:

Выполнение одиночного игрового действия переключает на другого игрока.
Выполнение двух игровых действий должно переключить обратно на первого игрока.
Выполнение выигрышного хода должно перевести игру в состояние Победа.
Выполнение хода, после которого больше нет ходов, переводит игру в состояние Ничья.


Роль Quick и Nimble в Behavior-Driven Testing


Тесты написаны на основе способа управления поведением и user stories, являются простыми предложениями на английском языке. Это значительно облегчает их понимание по сравнению с обычными Unit тестами, которые вы привыкли писать.

Quick и Nimble обеспечивают чрезвычайно мощный синтаксис, позволяющий писать тесты, которые читаются точно так же, как и обычные предложения, позволяя вам легко и быстро описывать поведение, которое вы хотите проверить. Внутри они работают точно так же, как и обычные XCTestCase(s).

Quick обеспечивает большую часть базового синтаксиса и возможностей, связанных с написанием тестов управляемых поведением, в то время как Nimble — это его сопутствующий фреймворк. Он предоставляет дополнительные выразительные возможности сопоставления и утверждения посредством Matchers, о которых вы узнаете чуть позже в этой статье.

Анатомия быстрого теста


Разбейте одну из пользовательских историй на три статьи на основе GWT — Given (действие/поведение, которое вы описываете), When (контекст этого действия/поведения) и Then (то что вы ожидаете увидеть):

Given/Дано: Пользователь играет.
When/Когда: Это один ход.
Then/Следовательно: Ход должен передаться другому игроку.

В Quick вы используете три функции: describe, context и it.

Anatomy of a Quick test


Написание первого теста


Наборы тестов в Quick называются Specs, и каждый созданный вами такой набор должен наследоваться от QuickSpec, таким же образом, как вы наследуете от XCTestCase в тестах. Набор тестов включает в себя основной метод spec(), который будет содержать все ваши тестовые примеры.

Стартовый проект уже содержит пустой набор тестов. Откройте файл AppTacToeTests / BoardSpec.swift и посмотрите на набор тестов BoardSpecс наследуемой от QuickSpec и содержащей единственный метод spec(), в котором вы будете работать.

Примечание: Когда вы откроете файл BoardSpec.swift, вы увидите сообщение об ошибке No such module 'Quick' или ‘Nimble’. Не волнуйтесь, так как это просто ошибка в Xcode, не связанная с проектом. Ваш код будет компилироваться и работать без каких-либо проблем.

Начните с добавления следующего кода внутрь метода spec():

var board: Board! // 1

beforeEach { // 2
  board = Board()
}

Этот код выполняет два действия:

  1. Определяет глобальную переменную board, которая будет использоваться в тестах.
  2. Устанавливаем для переменной board новый экземпляр Board перед каждым тестом, используя замыкание beforeEach с Quick's.

При помощи определенного базового шаблона вы можете начать написание первого теста!

По замыслу этого приложения, игра всегда начинается с Cross (то есть — или так или так., X), а противник будет Naught (то есть, O).

Начнем с первой user story, упомянутой выше: После выполнение первого хода следующий ход должен сделать второй игрок.

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

describe("playing") { // 1
  context("a single move") { // 2
    it("should switch to nought") { // 3
      try! board.playRandom() // 4
      expect(board.state).to(equal(.playing(.nought))) // 5
    }
  }
}

Вот что делает этот код:

  1. describe() используется для определения того, какие действия или поведение вы будете тестировать.
  2. context() используется для определения конкретного контекста действия, которое вы будете тестировать.
  3. it() используется для определения конкретного ожидаемого результата для теста.
  4. Вы выполняете случайный ход используя метод playRandom() в классе Board.
  5. Вы утверждаете, что состояние Board изменено на .playing(.nought). На этом этапе используется метод equal() из Nimble, который является одной из многих доступных функций, которые можно использовать для утверждения соответствия конкретных условий ожидаемому значению.

Примечание: Возможно, вы заметили принудительный вызов try и неявно unwrapped опционал для определения тестовых глобальных переменных. Хотя такой вариант обычно и не одобряется при написании кода в самом приложении, это довольно распространенная практика при написании тестов.

Запустите тесты, перейдя в панель меню Product ▸ Test или с помощью сочетания клавиш Command+U.

Так, вы увидите выполнение своего первого теста. Потрясающие!

После выполнения теста, вкладка Test navigator должна выглядеть так:

Your Test navigator will look like this

Вы уже заметили несколько интересных моментов, просмотрев код. Прежде всего, это чрезвычайно читабельный код. Просмотрев код теста, любой человек может относительно легко прочитать его, как простое предложение на английском:

Выполнение одиночного хода переключает на второго игрока.

На данном этапе Вы познакомились с простым использованием Nimble Matchers. Nimble использует эти сопоставления, чтобы вы могли получить ожидаемый результат теста очень быстрым способом, подобно простым предложениям. equal() — это только одна из функций сопоставления, которая доступная в Nimble. Вы даже можете создавать свои собственные функции.

Следующий тест


Вторая user story — “После второго хода должно переключится обратно на первого игрока” — звучит довольно похоже на user story.

Добавьте следующий код сразу после окончания предыдущего метода context(), внутри закрывающей фигурной скобки describe():

context("two moves") { // 1
  it("should switch back to cross") {
    try! board.playRandom() // 2
    try! board.playRandom()
    expect(board.state) == .playing(.cross) // 3
  }
}

Этот тест похож на предыдущий, но отличается только тем, что вы делаете два хода вместо одного.

Вот что тест выполняет:

  1. Вы определяете новый describe() для создания контекста «двух ходов». Вы можете иметь любое количество блоков describe() и context(), и они могут даже содержаться внутри друг друга. Поскольку вы все еще тестируете геймплей, вы добавили контекст внутри describe(«playing»).
  2. Вы совершаете для последовательных хода.
  3. Вы утверждаете, что state доски сейчас является .playing(.cross). Обратите внимание, что на этот раз вы использовали регулярный оператор равенства ==, вместо синтаксиса .to(equal()), который вы использовали ранее. Сопоставление Nimble’s equal() обеспечивает свои собственные перегруженные операторы, которые можно выбрать на свой вкус.

Arrange, Act & Assert


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

Следующие две user stories будут сложнее:

Выполнение выигрышного хода должно переключить в состояние Победы.
Выполнение выхода из игры не производит никаких действий, а только переход в состояние Завершения игры.


В обеих этих user stories вам нужно сделать некоторые шаги на игровой доске, чтобы Вы могли проверить ее состояние, свое утверждение.

Эти тесты обычно делятся на три этапа: Arrange, Act and Assert.

Прежде чем планировать тесты, вы должны понять, как реализована платформа Tic Tac Toe.

Board моделируется как Array, состоящий из 9 ячеек, адресованных с использованием индексов от 0 до 8.

image

При каждом шаге игрок выполняет один ход. Чтобы написать тест для симуляции победы пользователя, вам нужно будет привести Board до состояния, когда следующий ход будет выигрышным.

image

Теперь, когда вы понимаете, как работает Board, пришло время написать этот тест.

Добавьте следующий код ниже вашего предыдущего контекста “two moves” context():

context("a winning move") {
  it("should switch to won state") {
    // Arrange
    try! board.play(at: 0)
    try! board.play(at: 1)
    try! board.play(at: 3)
    try! board.play(at: 2)

    // Act
    try! board.play(at: 6)

    // Assert
    expect(board.state) == .won(.cross)
  }
}

Вот что реализует данный код:

Arrange: Вы организовываете Вoard, чтобы подготовить ее до состояния, когда следующий ход будет выигрышным. Вы делаете это, выполняя ходы обоих игроков в свою очередь; начиная с X в точке 0, в точке 1, X в 3 и наконец в 2.
Act: Вы выполняете ход Cross (X) на позицию 6. В текущем состоянии Board, этот ход должен привести к выигрышному состоянию.
Assert: Вы указываете, что игру выиграет Крестик (Х), и и Board перейдет в состояние Выигрыш (.won (.cross))

Запустите тест еще раз, используйте сочетание клавиш Command+U.

image

Что-то не так; вы сделали все верные шаги, но тест неожиданно провалился.

Добавьте следующий код непосредственно под строкой expect(), чтобы увидеть ошибку:

print(board)

Отобразив Board сразу же после блока Assert, вы получите детальное разъяснение данной ситуации:

image

Как видите, Board должен находиться в состоянии Победы, но тест все еще не работает. Похоже, вы нашли ошибку.

Перейдите в вкладку Project navigator и откройте Board.swift. Перейдите на вычисленное свойство isGameWon в строке 120.

Код в этом разделе проверяет все возможные выигрышные позиции по строкам, столбцам и диагоналям. Но, глядя на столбцы, у кода, похоже, только 2 столбца проверены, и на самом деле отсутствует один из вариантов выигрыша. Упс!

Добавьте следующую строку кода непосредственно ниже комментария // Columns:

[0, 3, 6],

Запустите тесты еще раз и насладитесь тремя зелеными маркеры!

image

Такой сценарий будет намного сложнее обнаружить с помощью обычных Unit тестов. Поскольку вы используете тестирование, основанное на поведении, вы фактически протестировали конкретный вариант использования приложения и обнаружили ошибку. Исправление основной реализации зафиксировало протестированное поведение, разрешив проблему, которую испытывала ваша пользовательская история.

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

К счастью, Quick предоставляет очень простой способ сделать это. Просто добавьте f (стоит для фокусировки ) перед любым из имен тестовых функций — имея it(), context() и describe (), становятся fit(), fcontext() и fdescribe()

Например, после замены it(«should switch to won state») на fit(«should switch to won state»), будет запускаться только этот конкретный тест, пропуская остальную часть тестового набора. Просто не забудьте удалить его после того, как вы закончите, иначе только часть ваших тестов будет работать!


Маленькое упражнение


Время для вызова. У вас есть одна последняя user story, которую вы еще не тестировали: Выполнение хода, после которого больше нет ходов, переводит игру в состояние Ничья.

Используя предыдущие примеры, напишите тест, чтобы проверить правильность определения Вoard.

Примечание: Чтобы достичь состояния выхода, вы можете последовательно воспроизводить следующие позиции: 0, 2, 1, 3, 4, 8, 6, 7.

В этом состоянии игровая позиция 5 должна приводить к тому, что ваш Вoard находится в состоянии draw.

Кроме того, используя метода .draw может запутать Xcode. Если это так, используйте полное выражение: Board.State.draw.

Если у вас не получилось роить это задание, вот решение:

context("a move leaving no remaining moves") {
  it("should switch to draw state") {
    // Arrange
    try! board.play(at: 0)
    try! board.play(at: 2)
    try! board.play(at: 1)
    try! board.play(at: 3)
    try! board.play(at: 4)
    try! board.play(at: 8)
    try! board.play(at: 6)
    try! board.play(at: 7)

    // Act
    try! board.play(at: 5)

    // Assert
    expect(board.state) == Board.State.draw
  }
}

Счастливый Путь — это не единственный путь


Все тесты, которые вы написали до сих пор, имеют одну общую черту: они описывают правильное поведение вашего приложения, следуя счастливому пути (happy path). Вы подтвердили, что, когда игрок воспроизводит правильные ходы, игра ведет себя правильно. Но как насчет не очень счастливого пути?

При написании тестов вы не должны забывать о концепции ожидаемых ошибок. У вас, как разработчика, должна быть возможность подтвердить правильность поведения Вoard даже если ваш игрок ведет себя не правильно (например, совершает неразрешенный ход).

Рассмотрите две последние user stories этого туториала:

делая ход, который уже был сделан, должно вызвать ошибку.
делая ход, после того как игра выиграна, должно вызывать ошибку.

Nimble предоставляет удобного сопоставителя с именем throwError(), которого вы можете использовать для проверки этого поведения.

Начните с проверки того, что уже воспроизведенный ход не может быть воспроизведен снова.

Добавьте следующий код прямо под последним context(), который вы добавили, но все еще внутри блока describe(«playing»):

context("a move that was already played") {
  it("should throw an error") {
    try! board.play(at: 0) // 1

    // 2
    expect { try board.play(at: 0) }
      .to(throwError(Board.PlayError.alreadyPlayed))
  }
}

Вот что выполняет данный код:

  1. Вы выполняете ход в положение 0.
  2. Вы воспроизводите ход в одну и ту же позицию и ожидаете, что он выбросит Board.PlayerError.alreadyPlayed. Когда вы утверждаете, что ошибка отображена, expect принимает замыкание, в котором вы можете запустить код, вызывающий ошибку.

Как вы и ожидали от Quick-тестов, утверждение читается так же, как английское предложение: expect playing the board to throw error – already played(ожидается, что дальнейшая игра вызовет ошибку «уже сыграно»).

Запустите набор тестов еще раз, перейдя в Product ▸ Test или используйте сочетание клавиш Command+U.

image

Последняя user story, которую вы собираетесь сегодня изучить, будет: Делая ход, после того как игра выиграна, должно вызывать ошибку.

Этот тест должен быть относительно похож на предыдущие тесты Arrange, Act и Assert: вам нужно привести board в выигрышное состояние, а затем попытаться сыграть еще один шаг, пока board будет в этом состоянии.

Добавьте следующий код прямо под последним context(), который вы добавили для предыдущего теста:

context("a move while the game was already won") {
  it("should throw an error") {
    // Arrange
    try! board.play(at: 0)
    try! board.play(at: 1)
    try! board.play(at: 3)
    try! board.play(at: 2)
    try! board.play(at: 6)

    // Act & Assert
    expect { try board.play(at: 7) }
      .to(throwError(Board.PlayError.noGame))
  }
}

Основываясь на знаниях, которые вы получили в этом уроке, вы должны чувствовать себя как дома, работая с этим тестом!

Вы подводите board к состоянию Выигрыш (.won (.cross)), воспроизведя 5 шагов… Затем вы Act и Assert, пытаясь сыграть ход, пока board уже находится в состоянии Выигрыш, и ожидает отображения Board.PlayError.noGame.

Запустите свой пакет тестов еще раз и похлопайте себя по спине пройдя все эти тесты!

image

Пользовательские сопоставления


При написании тестов в этой статье вы уже использовали несколько сопоставлений, встроенных в Nimble: equal() (и его == оператор перегрузки), и .throwError().

Иногда вы хотите создать свои собственные сопоставления, чтобы инкапсулировать некоторую сложную форму сопоставления или повысить читаемость некоторых ваших существующих тестов.

Подумайте о том, как улучшить читаемость user story «побеждающий ход должен переключить состояние на Выигрыш», упомянутого ранее:

expect(board.state) == .won(.cross)

Перефразируйте данный код, как предложение на английском языке: expect board to be won by cross (ожидается, что борд выиграет Крестик(х)). Тогда тест будет иметь следующий вид:

expect(board).to(beWon(by: .cross))

Сопоставители в Nimble — это не что иное, как простые функции, возвращающие Predicate , где generic T — тип, с которым вы сравниваете. В вашем случае T будет иметь тип Board.

В навигаторе проекта щелкните правой кнопкой мыши папку AppTacToeTests и выберите New File. Выберите Swift File и нажмите Next. Назовите свой файл Board+Nimble.swift. Убедитесь, что вы правильно установили файл в качестве члена вашей целевой задачи AppTacToeTests:

image

Замените стандартный import Foundation тремя следующими импортами:

import Quick
import Nimble
@testable import AppTacToe

Этот код выполняет импорт Quick и Nimble, а также импортирует вашу главную цель, поэтому вы можете использовать Board в своем сопоставлении.

Как упоминалось ранее, Matcher — это простая функция, возвращающая Predicate типа Board.

Добавьте основную часть сопоставления внизу импорта:

func beWon(by: Board.Mark) -> Predicate<Board> {
  return Predicate { expression in
    // Error! ...your custom predicate implementation goes here
  }
}

Этот код определяет сопоставление beWon(by:) которое возвращает Predicate , поэтому он правильно сопоставляется с Board.

Внутри вашей функции вы возвращаете новый экземпляр Predicate, передавая ему замыкание с единственным аргументом — expression — которое является значением или выражением, с которым вы сравниваете. Замыкание должно вернуть PredicateResult.

На этом этапе вы увидите ошибку компиляции, поскольку результат еще не возвращен. Далее это будет исправлено.

Чтобы создать PredicateResult, вы должны учитывать следующие случаи:
Как работает сопоставление beWon(by :)

image

Добавьте следующий код внутрь замыкания Predicate, заменив комментарий, // Error!:

// 1
guard let board = try expression.evaluate() else {
  return PredicateResult(status: .fail,
                         message: .fail("failed evaluating expression"))
}

// 2
guard board.state == .won(by) else {
  return PredicateResult(status: .fail,
                         message: .expectedCustomValueTo("be Won by \(by)", "\(board.state)"))
}

// 3
return PredicateResult(status: .matches,
                       message: .expectedTo("expectation fulfilled"))

Вначале эта предикативная реализация может показаться запутанной, но все довольно просто, если выполнить ее шаг за шагом:

  1. Вы пытаетесь оценить выражение, переданное в expect(). В этом случае выражение является самим board. Если оценка была провалена, вы возвращаете неудачный PredicateResult с соответствующим сообщением.
  2. Вы подтверждаете, что состояние board равно .won(by), где by — аргумент, переданный функции Matcher. Если состояние не совпадает, вы возвращаете ошибку PredicateResult с сообщением .expectedCustomValueTo.
  3. Наконец, если все выглядит хорошо и проверено, вы возвращаете успешный PredicateResult.

Это оно! Откройте BoardSpec.swift и замените следующую строку:

expect(board.state) == .won(.cross)

использовав новое сопоставление:
expect(board).to(beWon(by: .cross))

Запустите тесты еще раз, перейдя в Product ▸ Test или используйте сочетание клавиш Command + U. Вы должны увидеть, что все ваши тесты все еще проходят, но на этот раз с новым Matcher!

Что далее?


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

Вы узнали все о тестировании user stories, вместо того, чтобы тестировать детали реализации, и как Quick помогает этого достичь. Вы также узнали о сопоставлениях Nimble и даже написали свое собственное сопоставление. Здорово!

Чтобы начать работу с Quick и Nimble в своем собственном проекте, начните с руководства по установке и выберите способ установки, который будет приемлемым для вашего проекта.

Когда у вы все настроите, и захотите узнать больше о Quick, перейдите по ссылке под названием официальная документация Quick's. Прочтите также Readme Nimble, чтобы ознакомиться с огромным количеством доступных сопоставлений и возможностей.
Tags:
Hubs:
+4
Comments0

Articles

Change theme settings