76. Пишем unit-тесты на модель в SwiftUI

76. Пишем unit-тесты на модель в SwiftUI

Oleg991

Есть разные подходы к тестированию кода, в том числе разные инструменты и лучшие практики. В этой статье поделюсь своими аргументами в пользу unit-тестов, расскажу почему я их пишу, покажу простой и понятный пример.

Swift Testing - инструмент для тестирования в Xcode 16+ (Swift 6+)

Почему я пишу unit-тесты

Благодаря unit-тестам я:

  • совершаю меньше ошибок в своем коде
  • нахожу ошибки до передачи фичи тестировщикам
  • упрощаю подход к работе с фичами

Про ошибки

Я складываю бизнес-логику в модели, а когда пишу тесты, иногда нахожу ошибки в реализации. В ТЗ что-то может быть пропущено, или подразумевается как "очевидное" - такие вещи я иногда пропускаю при разработке, и благодаря тестам нахожу их быстро, до передачи кода на ревью.

Бывает, что в процессе или после кодревью вспоминаю, что можно написать еще какой-то тест, и когда пишу его, то опять же могу найти пропущенный кусок логики в модели.

Про упрощение работы с фичами

Помнить наизусть всю логику, которую мы реализуем - нерационально. При этом если написать тесты, то может быть ощутимо проще вспомнить, что и как должно работать.

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

Подопытная вьюха

Сделаем простую вьюху для экрана авторизации:

  1. Нужно сверстать текстовое поле для логина, текстовое поле для пароля, кнопку для авторизации
  2. Логин должен быть не меньше 4 символов в длину
  3. Пароль должен быть не меньше 10 символов в длину и содержать как минимум одну цифру и один специальный символ.

Данные для экрана (пароль, логин) будут находиться внутри структуры, которая является моделью для экрана, и в этой же модели должно быть вычисляемые свойства для бизнес-логики (пункты 2 и 3 из требований).

Скриншот вьюхи, где пароль не соответствует требованиям
Скриншот вьюхи, где логин и пароль соответствуют требованиям

Код для модели

struct AuthViewModel {
  var username = ""
  var password = ""
   
  /// Проверка доступности кнопки авторизации
  var canLogIn: Bool {
    isUsernameValid && isPasswordValid
  }
   
  /// Проверка валидности логина
  var isUsernameValid: Bool {
    username.count >= 4
  }
   
  /// Проверка валидности пароля
  var isPasswordValid: Bool {
    password.count >= 10 &&
    password.rangeOfCharacter(from: .decimalDigits) != nil &&
    password.rangeOfCharacter(from: .punctuationCharacters) != nil
  }
}

У модели есть 2 изменяемых поля (username, password) и вычисляемые поля, на которые мы и напишем тесты.

Пишем тесты

Подготовка проекта

На момент написания статьи в релизе используется Xcode 15.4, поэтому пишу тесты с использованием XCTest. Когда в релизе будет Xcode 16+, напишу обновленную статью с использованием Swift Testing.

Сделаем тестовый таргет и добавим файл для тестов:

Слева - структура файлов, выделены unit-тесты, а справа - таргеты проекта

Подготовка файла для тестов

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

  • пара импортов
  • класс-наследник XCTestCase
  • свойство для хранения модели, которую будем тестировать
import XCTest
@testable import DemoApp

final class AuthViewModelTests: XCTestCase {
  private var viewModel: AuthViewModel!
   
  override func setUp() {
    super.setUp()
    viewModel = AuthViewModel()
  }
   
  override func tearDown() {
    viewModel = nil
    super.tearDown()
  }
}

Метод setUp вызывается перед каждым тестом и инициализирует экземпляр AuthViewModel, а метод tearDown очищает экземпляр после выполнения тестов.

Планируем сценарии для тестов

Можно написать 11 методов для проверок всех сценариев авторизации на нашем экране (слева сценарий на русском, справа название тестового метода), при этом названия тестовых методов обязательно должны начинаться со слова test:

  1. Логин должен быть валидным (>= 4 символа) — testValidUsername
  2. Логин должен быть невалидным (< 4 символа) — testInvalidUsernameTooShort
  3. Логин не должен быть пустым — testInvalidUsernameEmpty
  4. Пароль должен быть валидным (>= 10 символов, 1 цифра, 1 специальный символ) — testValidPassword
  5. Пароль должен быть невалидным (< 10 символов) — testInvalidPasswordTooShort
  6. Пароль должен быть невалидным (без специального символа) — testInvalidPasswordNoSpecialCharacter
  7. Пароль должен быть невалидным (без цифры) — testInvalidPasswordNoDigit
  8. Кнопка должна быть доступна при валидных логине и пароле — testButtonEnabledWithValidCredentials
  9. Кнопка должна быть недоступна при невалидном логине — testButtonDisabledWithInvalidUsername
  10. Кнопка должна быть недоступна при невалидном пароле — testButtonDisabledWithInvalidPassword
  11. Кнопка должна быть недоступна при пустых данных — testButtonDisabledWithEmptyCredentials

Пишем тесты

Готовый файл с тестами:

import XCTest
@testable import DemoApp

final class AuthViewModelTests: XCTestCase {
  private var viewModel: AuthViewModel!
   
  override func setUp() {
    super.setUp()
    viewModel = AuthViewModel()
  }
   
  override func tearDown() {
    viewModel = nil
    super.tearDown()
  }
   
  func testValidUsername() {
    viewModel.username = "user"
    XCTAssertTrue(viewModel.isUsernameValid, "Логин должен быть валидным (>= 4 символа)")
  }
   
  func testInvalidUsernameTooShort() {
    viewModel.username = "usr"
    XCTAssertFalse(viewModel.isUsernameValid, "Логин должен быть невалидным (< 4 символа)")
  }
   
  func testInvalidUsernameEmpty() {
    viewModel.username = ""
    XCTAssertFalse(viewModel.isUsernameValid, "Логин не должен быть пустым")
  }
   
  func testValidPassword() {
    viewModel.password = "Password1!"
    XCTAssertTrue(viewModel.isPasswordValid, "Пароль должен быть валидным (>= 10 символов, 1 цифра, 1 специальный символ)")
  }
   
  func testInvalidPasswordTooShort() {
    viewModel.password = "Pass1"
    XCTAssertFalse(viewModel.isPasswordValid, "Пароль должен быть невалидным (< 10 символов)")
  }
   
  func testInvalidPasswordNoSpecialCharacter() {
    viewModel.password = "PasswordWithoutSpecialChar1"
    XCTAssertFalse(viewModel.isPasswordValid, "Пароль должен быть невалидным (без специального символа)")
  }
   
  func testInvalidPasswordNoDigit() {
    viewModel.password = "Password!"
    XCTAssertFalse(viewModel.isPasswordValid, "Пароль должен быть невалидным (без цифры)")
  }
   
  func testButtonEnabledWithValidCredentials() {
    viewModel.username = "user"
    viewModel.password = "Password1!"
    XCTAssertTrue(viewModel.canLogIn, "Кнопка должна быть доступна при валидных логине и пароле")
  }
   
  func testButtonDisabledWithInvalidUsername() {
    viewModel.username = "usr"
    viewModel.password = "Password1!"
    XCTAssertFalse(viewModel.canLogIn, "Кнопка должна быть недоступна при невалидном логине")
  }
   
  func testButtonDisabledWithInvalidPassword() {
    viewModel.username = "user"
    viewModel.password = "Pass1"
    XCTAssertFalse(viewModel.canLogIn, "Кнопка должна быть недоступна при невалидном пароле")
  }
   
  func testButtonDisabledWithEmptyCredentials() {
    viewModel.username = ""
    viewModel.password = ""
    XCTAssertFalse(viewModel.canLogIn, "Кнопка должна быть недоступна при пустых данных")
  }
}

Заключение

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

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

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

Код для этой статьи можно посмотреть тут, другие статьи по разработке - тут, а про инвестиции - тут.

Report Page