- Зачем нужны тесты
- Unit Tests (XCTest)
- Mocking и Dependency Injection
- UI Tests
- Test-Driven Development (TDD)
- Best Practices
- Q&A Карточки
- Уверенность в коде – код работает как ожидается
- Предотвращение регрессий – новый код не ломает старый функционал
- Документация – тесты показывают как использовать код
- Рефакторинг – можно безопасно менять код
- Быстрая обратная связь – находим баги до production
/\
/ \
/ \ UI Tests (End-to-End)
/------\
/ \ Integration Tests
/----------\
/ Unit \ Unit Tests
/--------------\
- Unit Tests – тестируют отдельные функции/классы (самые быстрые)
- Integration Tests – тестируют взаимодействие компонентов
- UI Tests – тестируют весь app flow (самые медленные)
import XCTest
@testable import MyApp // Доступ к internal классам
class CalculatorTests: XCTestCase {
// Выполняется перед КАЖДЫМ тестом
override func setUp() {
super.setUp()
// Подготовка (создание объектов)
}
// Выполняется после КАЖДОГО теста
override func tearDown() {
// Очистка
super.tearDown()
}
// Тест: название должно начинаться с test
func testAddition() {
let calculator = Calculator()
let result = calculator.add(2, 3)
XCTAssertEqual(result, 5)
}
func testSubtraction() {
let calculator = Calculator()
let result = calculator.subtract(5, 3)
XCTAssertEqual(result, 2)
}
}class AssertionTests: XCTestCase {
func testEquality() {
XCTAssertEqual(2 + 2, 4)
XCTAssertNotEqual(2 + 2, 5)
}
func testBooleans() {
XCTAssertTrue(5 > 3)
XCTAssertFalse(5 < 3)
}
func testNil() {
let value: String? = nil
XCTAssertNil(value)
let nonNil: String? = "Hello"
XCTAssertNotNil(nonNil)
}
func testErrors() {
XCTAssertThrowsError(try riskyFunction()) { error in
XCTAssertEqual(error as? MyError, MyError.invalid)
}
XCTAssertNoThrow(try safeFunction())
}
func testArrays() {
let array = [1, 2, 3]
XCTAssertEqual(array.count, 3)
XCTAssertTrue(array.contains(2))
}
}// ViewModel
@MainActor
class PostListViewModel: ObservableObject {
@Published var posts: [Post] = []
@Published var isLoading = false
@Published var errorMessage: String?
private let repository: PostRepositoryProtocol
init(repository: PostRepositoryProtocol) {
self.repository = repository
}
func loadPosts() async {
isLoading = true
errorMessage = nil
do {
posts = try await repository.fetchPosts()
} catch {
errorMessage = error.localizedDescription
}
isLoading = false
}
}
// Tests
@MainActor
class PostListViewModelTests: XCTestCase {
var viewModel: PostListViewModel!
var mockRepository: MockPostRepository!
override func setUp() {
super.setUp()
mockRepository = MockPostRepository()
viewModel = PostListViewModel(repository: mockRepository)
}
override func tearDown() {
viewModel = nil
mockRepository = nil
super.tearDown()
}
func testLoadPostsSuccess() async {
// Given (Arrange)
let expectedPosts = [
Post(id: 1, title: "Test", body: "Body", userId: 1)
]
mockRepository.postsToReturn = expectedPosts
// When (Act)
await viewModel.loadPosts()
// Then (Assert)
XCTAssertEqual(viewModel.posts.count, 1)
XCTAssertEqual(viewModel.posts.first?.title, "Test")
XCTAssertFalse(viewModel.isLoading)
XCTAssertNil(viewModel.errorMessage)
}
func testLoadPostsFailure() async {
// Given
mockRepository.shouldFail = true
// When
await viewModel.loadPosts()
// Then
XCTAssertTrue(viewModel.posts.isEmpty)
XCTAssertNotNil(viewModel.errorMessage)
XCTAssertFalse(viewModel.isLoading)
}
func testLoadingState() async {
// Given
mockRepository.delay = 0.5
// When
let task = Task {
await viewModel.loadPosts()
}
// Проверяем что isLoading = true сразу после вызова
try? await Task.sleep(nanoseconds: 100_000_000) // 0.1 сек
XCTAssertTrue(viewModel.isLoading)
await task.value
// Then
XCTAssertFalse(viewModel.isLoading)
}
}Mock – фейковый объект, который имитирует поведение реального объекта для тестирования.
// ❌ Плохо: hard-coded зависимость
class BadViewModel {
let repository = PostRepository() // Нельзя заменить для тестов
func loadPosts() {
// ...
}
}
// ✅ Хорошо: DI через конструктор
class GoodViewModel {
private let repository: PostRepositoryProtocol
init(repository: PostRepositoryProtocol) { // Можно передать mock
self.repository = repository
}
func loadPosts() {
// ...
}
}// Protocol
protocol PostRepositoryProtocol {
func fetchPosts() async throws -> [Post]
func createPost(_ post: Post) async throws -> Post
}
// Production implementation
class PostRepository: PostRepositoryProtocol {
func fetchPosts() async throws -> [Post] {
// Real network call
let (data, _) = try await URLSession.shared.data(from: url)
return try JSONDecoder().decode([Post].self, from: data)
}
func createPost(_ post: Post) async throws -> Post {
// Real network call
// ...
}
}
// Mock для тестов
class MockPostRepository: PostRepositoryProtocol {
var postsToReturn: [Post] = []
var postToReturn: Post?
var shouldFail = false
var error: Error = NetworkError.invalidResponse
var delay: TimeInterval = 0
var fetchPostsCallCount = 0
var createPostCallCount = 0
func fetchPosts() async throws -> [Post] {
fetchPostsCallCount += 1
if delay > 0 {
try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
}
if shouldFail {
throw error
}
return postsToReturn
}
func createPost(_ post: Post) async throws -> Post {
createPostCallCount += 1
if shouldFail {
throw error
}
return postToReturn ?? post
}
}class CreatePostUseCaseTests: XCTestCase {
var useCase: CreatePostUseCase!
var mockRepository: MockPostRepository!
override func setUp() {
super.setUp()
mockRepository = MockPostRepository()
useCase = CreatePostUseCase(repository: mockRepository)
}
func testCreatePostSuccess() async throws {
// Given
let newPost = Post(id: 0, title: "New", body: "Body", userId: 1)
let expectedPost = Post(id: 1, title: "New", body: "Body", userId: 1)
mockRepository.postToReturn = expectedPost
// When
let result = try await useCase.execute(post: newPost)
// Then
XCTAssertEqual(result.id, 1)
XCTAssertEqual(mockRepository.createPostCallCount, 1)
}
func testCreatePostFailure() async {
// Given
let newPost = Post(id: 0, title: "New", body: "Body", userId: 1)
mockRepository.shouldFail = true
mockRepository.error = NetworkError.invalidResponse
// When/Then
do {
_ = try await useCase.execute(post: newPost)
XCTFail("Should throw error")
} catch {
XCTAssertEqual(error as? NetworkError, .invalidResponse)
}
}
}// Protocol для URLSession
protocol URLSessionProtocol {
func data(from url: URL) async throws -> (Data, URLResponse)
}
extension URLSession: URLSessionProtocol {}
// NetworkService с DI
class NetworkService {
private let session: URLSessionProtocol
init(session: URLSessionProtocol = URLSession.shared) {
self.session = session
}
func fetchPosts() async throws -> [Post] {
let url = URL(string: "https://api.example.com/posts")!
let (data, _) = try await session.data(from: url)
return try JSONDecoder().decode([Post].self, from: data)
}
}
// Mock URLSession
class MockURLSession: URLSessionProtocol {
var dataToReturn: Data?
var errorToThrow: Error?
func data(from url: URL) async throws -> (Data, URLResponse) {
if let error = errorToThrow {
throw error
}
let response = HTTPURLResponse(
url: url,
statusCode: 200,
httpVersion: nil,
headerFields: nil
)!
return (dataToReturn ?? Data(), response)
}
}
// Tests
class NetworkServiceTests: XCTestCase {
func testFetchPostsSuccess() async throws {
// Given
let mockSession = MockURLSession()
let posts = [Post(id: 1, title: "Test", body: "Body", userId: 1)]
mockSession.dataToReturn = try JSONEncoder().encode(posts)
let service = NetworkService(session: mockSession)
// When
let result = try await service.fetchPosts()
// Then
XCTAssertEqual(result.count, 1)
XCTAssertEqual(result.first?.title, "Test")
}
}import XCTest
class MyAppUITests: XCTestCase {
var app: XCUIApplication!
override func setUp() {
super.setUp()
continueAfterFailure = false
app = XCUIApplication()
app.launch()
}
func testLoginFlow() {
// Given: экран логина
let emailTextField = app.textFields["Email"]
let passwordSecureField = app.secureTextFields["Password"]
let loginButton = app.buttons["Login"]
// When: ввод данных и тап на Login
emailTextField.tap()
emailTextField.typeText("test@example.com")
passwordSecureField.tap()
passwordSecureField.typeText("password123")
loginButton.tap()
// Then: проверяем что показался home screen
let welcomeText = app.staticTexts["Welcome"]
XCTAssertTrue(welcomeText.waitForExistence(timeout: 5))
}
func testPostListDisplaysPosts() {
// Given: главный экран
let postList = app.tables["PostList"]
// When: ждём загрузки
XCTAssertTrue(postList.waitForExistence(timeout: 5))
// Then: проверяем что есть посты
XCTAssertTrue(postList.cells.count > 0)
let firstCell = postList.cells.element(boundBy: 0)
XCTAssertTrue(firstCell.exists)
}
func testCreatePost() {
// Tap на кнопку создания
app.buttons["CreatePost"].tap()
// Заполнить форму
let titleField = app.textFields["PostTitle"]
titleField.tap()
titleField.typeText("New Post")
let bodyField = app.textViews["PostBody"]
bodyField.tap()
bodyField.typeText("Post body text")
// Submit
app.buttons["Submit"].tap()
// Проверить что пост создан
let newPost = app.staticTexts["New Post"]
XCTAssertTrue(newPost.waitForExistence(timeout: 5))
}
}// SwiftUI
struct LoginView: View {
@State private var email = ""
var body: some View {
TextField("Email", text: $email)
.accessibilityIdentifier("EmailTextField") // ✅ Для UI тестов
}
}
// UIKit
class LoginViewController: UIViewController {
@IBOutlet weak var emailTextField: UITextField!
override func viewDidLoad() {
super.viewDidLoad()
emailTextField.accessibilityIdentifier = "EmailTextField"
}
}
// UI Test
func testEmailInput() {
let emailField = app.textFields["EmailTextField"]
emailField.tap()
emailField.typeText("test@example.com")
}func testLoadingIndicator() {
// Tap на refresh
app.buttons["Refresh"].tap()
// Ждём появления loading indicator
let loadingIndicator = app.activityIndicators["LoadingIndicator"]
XCTAssertTrue(loadingIndicator.waitForExistence(timeout: 1))
// Ждём исчезновения (данные загрузились)
let notExistsPredicate = NSPredicate(format: "exists == false")
let expectation = XCTNSPredicateExpectation(
predicate: notExistsPredicate,
object: loadingIndicator
)
let result = XCTWaiter().wait(for: [expectation], timeout: 10)
XCTAssertEqual(result, .completed)
}func testScreenshot() {
// Arrange
app.launch()
// Take screenshot
let screenshot = app.screenshot()
let attachment = XCTAttachment(screenshot: screenshot)
attachment.name = "Home Screen"
attachment.lifetime = .keepAlways
add(attachment)
}1. 🔴 RED: Написать failing test
2. 🟢 GREEN: Написать минимальный код, чтобы тест прошёл
3. 🔵 REFACTOR: Улучшить код
// 1. 🔴 RED: Пишем failing test
class StringValidatorTests: XCTestCase {
func testEmailValidation() {
let validator = StringValidator()
XCTAssertTrue(validator.isValidEmail("test@example.com"))
XCTAssertFalse(validator.isValidEmail("invalid"))
}
}
// Compilation error: StringValidator doesn't exist
// 2. 🟢 GREEN: Минимальный код
class StringValidator {
func isValidEmail(_ email: String) -> Bool {
return email.contains("@") // Простейшая проверка
}
}
// Тест проходит ✅
// 3. 🔵 REFACTOR: Улучшаем код
class StringValidator {
func isValidEmail(_ email: String) -> Bool {
let emailRegex = "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,64}"
let predicate = NSPredicate(format: "SELF MATCHES %@", emailRegex)
return predicate.evaluate(with: email)
}
}
// Добавляем больше тестов
func testEmailValidationEdgeCases() {
let validator = StringValidator()
XCTAssertTrue(validator.isValidEmail("user+tag@example.com"))
XCTAssertFalse(validator.isValidEmail("@example.com"))
XCTAssertFalse(validator.isValidEmail("user@"))
XCTAssertFalse(validator.isValidEmail("user"))
}func testCalculatorAddition() {
// Arrange (Given) - подготовка
let calculator = Calculator()
let a = 5
let b = 3
// Act (When) - действие
let result = calculator.add(a, b)
// Assert (Then) - проверка
XCTAssertEqual(result, 8)
}// ❌ Плохо: несколько проверок в одном тесте
func testCalculator() {
XCTAssertEqual(calculator.add(2, 3), 5)
XCTAssertEqual(calculator.subtract(5, 3), 2)
XCTAssertEqual(calculator.multiply(2, 3), 6)
}
// ✅ Хорошо: отдельный тест для каждой операции
func testAddition() {
XCTAssertEqual(calculator.add(2, 3), 5)
}
func testSubtraction() {
XCTAssertEqual(calculator.subtract(5, 3), 2)
}
func testMultiplication() {
XCTAssertEqual(calculator.multiply(2, 3), 6)
}// ❌ Плохо
func test1() { }
func testFunc() { }
// ✅ Хорошо
func testAdditionWithPositiveNumbers() { }
func testLoginFailsWithInvalidCredentials() { }
func testViewModelLoadsPostsSuccessfully() { }// ❌ Плохо: тестируем private метод
class ViewModelTests: XCTestCase {
func testPrivateMethod() {
// Нельзя тестировать private метод напрямую
}
}
// ✅ Хорошо: тестируем публичное поведение
func testLoadPostsUpdatesPostsArray() {
// Тестируем результат, а не детали реализации
}extension Post {
static func fixture(
id: Int = 1,
title: String = "Test Title",
body: String = "Test Body",
userId: Int = 1
) -> Post {
return Post(id: id, title: title, body: body, userId: userId)
}
}
// Использование
func testViewModel() {
let post = Post.fixture(title: "Custom Title")
viewModel.addPost(post)
XCTAssertEqual(viewModel.posts.first?.title, "Custom Title")
}// ❌ Плохо: тесты зависят друг от друга
class BadTests: XCTestCase {
static var sharedState = 0
func testA() {
BadTests.sharedState = 5
}
func testB() {
XCTAssertEqual(BadTests.sharedState, 5) // Зависит от testA!
}
}
// ✅ Хорошо: каждый тест независим
class GoodTests: XCTestCase {
var calculator: Calculator!
override func setUp() {
super.setUp()
calculator = Calculator() // Новый объект для каждого теста
}
func testA() {
calculator.value = 5
XCTAssertEqual(calculator.value, 5)
}
func testB() {
calculator.value = 10 // Не зависит от testA
XCTAssertEqual(calculator.value, 10)
}
}# Xcode: Product → Test → Show Code Coverage
# Смотрим какие части кода не покрыты тестамиЦель: 70-80% code coverage (не 100%!)
- UI код обычно не тестируется unit тестами
- Некоторый boilerplate код не критичен
Q1: Зачем нужны unit тесты?
A: Уверенность в коде, предотвращение регрессий, документация, безопасный рефакторинг, быстрая обратная связь.
Q2: В чём разница между unit и UI тестами?
A: Unit тесты проверяют отдельные функции/классы (быстрые), UI тесты проверяют весь app flow (медленные, проверяют UI).
Q3: Что такое mock объект?
A: Фейковый объект, который имитирует поведение реального для тестирования. Позволяет контролировать поведение зависимостей.
Q4: Зачем нужен Dependency Injection?
A: Чтобы можно было передавать mock объекты в тестах. Класс получает зависимости извне (через конструктор), а не создаёт их сам.
Q5: Что такое AAA pattern?
A: Arrange (Given) - подготовка, Act (When) - действие, Assert (Then) - проверка. Структура unit теста.
Q6: Что такое TDD?
A: Test-Driven Development: сначала пишешь failing test (Red), потом минимальный код для прохождения (Green), потом рефакторинг (Refactor).
Q7: Как протестировать async/await код?
A: Использовать async тестовые методы: func testLoadPosts() async { await viewModel.loadPosts() ... }
Q8: Зачем нужен accessibilityIdentifier?
A: Для UI тестов. Позволяет находить элементы в UI: app.buttons["LoginButton"]
Q9: Что такое test fixture?
A: Предопределённые тестовые данные. Удобные factory методы для создания объектов в тестах: Post.fixture(title: "Test")
Q10: Какой должен быть code coverage?
A: 70-80% считается хорошо. 100% не нужен (UI код, boilerplate обычно не тестируются).
- Знаю зачем нужны тесты
- Понимаю разницу между unit/integration/UI тестами
- Умею писать unit тесты с XCTest
- Знаю основные assertions (XCTAssertEqual, XCTAssertTrue, etc.)
- Понимаю Dependency Injection и mocking
- Умею создавать mock объекты
- Знаю как тестировать async/await код
- Понимаю AAA pattern (Arrange-Act-Assert)
- Знаю что такое TDD
- Умею писать UI тесты с XCUITest
- Знаю про accessibilityIdentifier
- Понимаю best practices (изоляция, один тест = одна проверка)
- Unit Tests – тестируют отдельные функции/классы (быстрые, много тестов)
- UI Tests – тестируют весь app flow (медленные, критичные сценарии)
- Dependency Injection – обязательно для тестируемого кода
- Mock объекты – контролируют поведение зависимостей в тестах
- AAA Pattern – структура теста (Arrange-Act-Assert)
- TDD – Red-Green-Refactor цикл
- Best Practices – изоляция, понятные названия, одна проверка = один тест
- Code Coverage – стремись к 70-80%