iOS/Swift: подробное руководство по модульным и UI-тестам. Часть 1

Честно говоря, тестирование  —  это не так весело. Я бы предпочел просто запустить приложение, ни о чем другом не заботясь. Но, поскольку это неотъемлемая часть любого проекта, которая абсолютно необходима для хорошего пользовательского взаимодействия, продемонстрирую выполнение некоторых базовых тестов, помогая вам сэкономить время.

Вот что мы рассмотрим вместе с дополнительными рекомендациями.

Проект с тестированием

  • Создание проекта с тестами
  • Добавление тестов в имеющийся проект

Модульные тесты

  • Модели декодирования
  • HTTP-запросы с HTTP-заглушками

Тесты пользовательского интерфейса

  • Настройка идентификатора доступности
  • Инициализация представления
  • SearchBar
  • TableView
  • Навигация
  • Статичные и динамические UILabel

Покажу демо с очень простым примером для извлечения репозиториев GitHub по введенному ключевому слову. Этот проект наверняка пробовали все знакомые со Swift. Загружается он здесь. Читая статью, заглядывайте туда.

Настройка проекта

Новый проект

Сначала создадим проект с включенными тестами. Переходим в File («Файл»)New («Новый»)Project («Проект») и создаем новое App («Приложение»). Во всплывающем окне Choose options for your new project («Выбрать параметры нового проекта») обязательно отмечаем галочкой Include Tests («Включить тесты»). В проект автоматически добавится два пакета-цели: один для модульного теста, другой для теста пользовательского интерфейса.

Имеющийся проект

Чтобы добавить тесты в имеющийся проект, переходим в FileNewTarget («Цель»), выбираем Test («Тест») и даем ему название.

Main.Storyboard

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

Main.storyboard очень простой: Navigation Controller, основной ViewController с searchBar и tableView для отображения результата, detailViewController со staticLabel для показа теста и nameLabel для отображения названия выбранного репозитория:

Модели декодирования

Вот модели для декодирования ответа API:

//
// Repository.swift
// testsDemo
//
// Создано Itsuki 17.10.2023.
//

import Foundation

struct Repository: Codable {

var id: Int
var fullName: String?
var language: String?
var stargazersCount: Int?

}


struct RepositoryResponse: Codable {
var totalCount: Int
var items: [Repository]
}

GitHubService

Вот пользовательский класс для извлечения репозиториев с помощью GitHub API, я добавил пользовательский ServiceError для успешных и неудачных тестов:

//
// RepositoryManager.swift
// testsDemo
//
// Создано Itsuki 17.10.2023.
//

import Foundation


class GitHubService {

enum ServiceError: Error, CustomStringConvertible {
case urlCreation
case network
case parsing

var description: String {
switch self {
case .urlCreation:
return "Error Initializing URL."
case .network:
return "Network error: Please check on connection."
case .parsing:
return "Error parsing response."
}
}
}

static private let baseUrl: String = "https://api.github.com/search/repositories?q="


static func fetchRepositories(query: String) async throws -> [Repository] {

guard let urlString = "\(baseUrl)\(query)".addingPercentEncoding(withAllowedCharacters: NSCharacterSet.urlQueryAllowed), let url = URL(string: urlString)
else {
throw ServiceError.urlCreation
}

var request = URLRequest(url: url)
request.httpMethod = "GET"
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.timeoutInterval = 180

var data: Data!
var response: URLResponse!
do {
(data, response) = try await URLSession.shared.data(for: request)
} catch {
throw ServiceError.network
}

if let response = response as? HTTPURLResponse {
let statusCode = response.statusCode
if !(200...300 ~= statusCode ) {
throw ServiceError.network
}
} else {
throw ServiceError.network
}


let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
do {
let decodedResponse = try decoder.decode(RepositoryResponse.self, from: data)
return decodedResponse.items
} catch {
throw ServiceError.parsing
}


}

}

ViewController

Основным ViewController обрабатывается пользовательский ввод, с помощью созданного выше GitHubService извлекаются репозитории, а также загружается tableview и обрабатывается диапазон ячеек:

//
// ViewController.swift
// testsDemo
//
// Создано Itsuki 17.10.2023.
//

import UIKit

class ViewController: UIViewController {

@IBOutlet weak var tableView: UITableView!
@IBOutlet weak var searchBar: UISearchBar!

var repositories: [Repository] = []

override func viewDidLoad() {
super.viewDidLoad()
searchBar.delegate = self
tableView.delegate = self
tableView.dataSource = self

}


}


extension ViewController: UISearchBarDelegate {
func searchBarShouldBeginEditing(_ searchBar: UISearchBar) -> Bool {
return true
}



func searchBarSearchButtonClicked(_ searchBar: UISearchBar) {
searchBar.resignFirstResponder()
Task {
print("here")
do {
repositories = try await GitHubService.fetchRepositories(query:"test")
} catch (let error) {
print(error)
repositories = []
}

DispatchQueue.main.async {[weak self] in
guard let self = self else {return}
self.tableView.reloadData()
}
}
}
}


extension ViewController: UITableViewDelegate, UITableViewDataSource {
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return repositories.count
}

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "ReusableCell")! as UITableViewCell
cell.textLabel?.text = repositories[indexPath.row].fullName
return cell
}

func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
guard let detailViewController = self.storyboard?.instantiateViewController(withIdentifier: "DetailViewController") as? DetailViewController
else { return }
detailViewController.fullName = repositories[indexPath.row].fullName
navigationController?.pushViewController(detailViewController, animated: true)
}

}

DetailViewController

Задаем в nameLabel полное название выбранного репозитория:

//
// DetailViewController.swift
// testsDemo
//
// Создано Itsuki 17.10.2023.
//

import UIKit

class DetailViewController: UIViewController {

var fullName: String?
@IBOutlet weak var fullNameLabel: UILabel!

override func viewDidLoad() {
super.viewDidLoad()
fullNameLabel.text = fullName
}

}

После долгой настройки переходим к главному: тестам.

Тесты: общий обзор

Прежде чем вплотную заняться тестами, заглянем в Xcode.

Кроме обычного проекта, там созданы еще две папки: testsDemoTests и testsDemoUITests.

Открываем папку теста, пример кода уже включен:

//
// testsDemoTests.swift
// testsDemoTests
//
// Создано Itsuki 20.10.2023.
//

import XCTest

final class testsDemoTests: XCTestCase {

override func setUpWithError() throws {
// Помещаем сюда код настройки. Этот метод вызывается перед вызовом каждого тестового метода в классе.
}

override func tearDownWithError() throws {
// Помещаем сюда код завершения. Этот метод вызывается после вызова каждого тестового метода в классе.
}

func testExample() throws {
// Это пример тестового сценария функционального тестирования.
// Чтобы проверить корректность результатов тестов, используйте «XCTAssert» и связанные с ним функции.
// Любой тест, который пишется для XCTest, аннотируется как «throws» и «async».
// Чтобы при обнаружении неперехваченной ошибки тест выдавал неожиданный сбой, помечайте его как «throws».
// Чтобы разрешить в тесте ожидание завершения асинхронного кода, помечайте его как «async». После этого проверяйте результаты с помощью утверждений.
}

func testPerformanceExample() throws {
// Это пример тестового сценария тестирования производительности.
self.measure {
// Помещаем сюда код, время выполнения которого нужно измерить.
}
}

}

Важное замечание: каждый определяемый метод тестового сценария начинается с префикса test.

Скоро увидим это в примере при написании собственных тестов во второй части.

Читайте также:

Читайте нас в Telegram, VK и Дзен


Перевод статьи Itsuki: iOS/Swift: (Super Detailed) Guide to Unit Tests and UITests

Предыдущая статьяОрганизация “глобальных” провайдеров во Flutter Riverpod с помощью миксинов
Следующая статьяОсновы качественного анализа данных