Skip to content

Latest commit

 

History

History
832 lines (642 loc) · 23.6 KB

13_Testing_iOS.md

File metadata and controls

832 lines (642 loc) · 23.6 KB

Testing в iOS: Unit Tests, UI Tests, Mocks

📋 Содержание

  1. Зачем нужны тесты
  2. Unit Tests (XCTest)
  3. Mocking и Dependency Injection
  4. UI Tests
  5. Test-Driven Development (TDD)
  6. Best Practices
  7. Q&A Карточки

Зачем нужны тесты?

Преимущества

  1. Уверенность в коде – код работает как ожидается
  2. Предотвращение регрессий – новый код не ломает старый функционал
  3. Документация – тесты показывают как использовать код
  4. Рефакторинг – можно безопасно менять код
  5. Быстрая обратная связь – находим баги до production

Типы тестов

         /\
        /  \
       /    \  UI Tests (End-to-End)
      /------\  
     /        \ Integration Tests
    /----------\
   /   Unit     \ Unit Tests
  /--------------\
  • Unit Tests – тестируют отдельные функции/классы (самые быстрые)
  • Integration Tests – тестируют взаимодействие компонентов
  • UI Tests – тестируют весь app flow (самые медленные)

Unit Tests (XCTest)

Создание тестов

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)
    }
}

Основные Assertions

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

// 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)
    }
}

Mocking и Dependency Injection

Что такое Mock?

Mock – фейковый объект, который имитирует поведение реального объекта для тестирования.

Dependency Injection

// ❌ Плохо: 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() {
        // ...
    }
}

Создание Mock объектов

// 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
    }
}

Тестирование с Mock

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)
        }
    }
}

Mock URLSession

// 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")
    }
}

UI Tests

Создание UI тестов

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))
    }
}

Accessibility Identifiers

// 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)
}

Test-Driven Development (TDD)

Red-Green-Refactor цикл

1. 🔴 RED: Написать failing test
2. 🟢 GREEN: Написать минимальный код, чтобы тест прошёл
3. 🔵 REFACTOR: Улучшить код

Пример TDD

// 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"))
}

Best Practices

1. AAA Pattern (Arrange-Act-Assert)

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)
}

2. Один тест = одна проверка

// ❌ Плохо: несколько проверок в одном тесте
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)
}

3. Понятные названия тестов

// ❌ Плохо
func test1() { }
func testFunc() { }

// ✅ Хорошо
func testAdditionWithPositiveNumbers() { }
func testLoginFailsWithInvalidCredentials() { }
func testViewModelLoadsPostsSuccessfully() { }

4. Тестируй публичный API, не детали реализации

// ❌ Плохо: тестируем private метод
class ViewModelTests: XCTestCase {
    func testPrivateMethod() {
        // Нельзя тестировать private метод напрямую
    }
}

// ✅ Хорошо: тестируем публичное поведение
func testLoadPostsUpdatesPostsArray() {
    // Тестируем результат, а не детали реализации
}

5. Используй фикстуры (test fixtures)

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")
}

6. Изоляция тестов

// ❌ Плохо: тесты зависят друг от друга
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)
    }
}

7. Test Coverage (покрытие кодом)

# Xcode: Product → Test → Show Code Coverage
# Смотрим какие части кода не покрыты тестами

Цель: 70-80% code coverage (не 100%!)

  • UI код обычно не тестируется unit тестами
  • Некоторый boilerplate код не критичен

Q&A Карточки

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 обычно не тестируются).


Checklist для собеседования

  • Знаю зачем нужны тесты
  • Понимаю разницу между 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 (изоляция, один тест = одна проверка)

Резюме

  1. Unit Tests – тестируют отдельные функции/классы (быстрые, много тестов)
  2. UI Tests – тестируют весь app flow (медленные, критичные сценарии)
  3. Dependency Injection – обязательно для тестируемого кода
  4. Mock объекты – контролируют поведение зависимостей в тестах
  5. AAA Pattern – структура теста (Arrange-Act-Assert)
  6. TDD – Red-Green-Refactor цикл
  7. Best Practices – изоляция, понятные названия, одна проверка = один тест
  8. Code Coverage – стремись к 70-80%