MVVM vs The Composable Architecture (TCA)
Deux des patterns architecturaux les plus populaires en iOS s'affrontent : le MVVM traditionnel face au TCA fonctionnel de Point-Free. Comment choisir la bonne architecture ?
Vitesse, isolation et boucle de feedback fiable
Interaction réelle des composants, validation de bout en bout
| Catégorie | Unit Test | Integration Test |
|---|---|---|
| Performance | 10/10 | 4/10 |
| Facilité d'apprentissage | 8/10 | 6/10 |
| Écosystème | 9/10 | 8/10 |
| Communauté | 9/10 | 8/10 |
| Marché de l'emploi | 9/10 | 8/10 |
| Pérennité | 9/10 | 9/10 |
// Swift - Exemples de tests unitaires (XCTest + Swift Testing)
import Testing
import Foundation
@testable import MyApp
// Framework Swift Testing (iOS 17+, WWDC 2024)
struct PriceFormatterTests {
@Test("Le formatage de la livre turque doit être correct")
func turkishLiraFormat() {
let formatter = PriceFormatter(locale: Locale(identifier: "tr_TR"))
#expect(formatter.format(1234.5) == "₺1.234,50")
#expect(formatter.format(0) == "₺0,00")
#expect(formatter.format(-50) == "-₺50,00")
}
@Test("Un prix invalide ne doit pas être négatif",
arguments: [-1.0, -100.0, -0.01])
func negativePriceValidation(price: Double) {
let validator = PriceValidator()
#expect(!validator.isValid(price))
}
}
struct CartViewModelTests {
var sut: CartViewModel!
var mockRepository: MockCartRepository!
@Test("Le prix total doit être mis à jour quand un produit est ajouté")
mutating func addProductUpdatesTotalPrice() async throws {
mockRepository = MockCartRepository()
sut = CartViewModel(repository: mockRepository)
let product = Product(id: "p1", name: "MacBook", price: 75000)
await sut.addProduct(product)
#expect(sut.totalPrice == 75000)
#expect(sut.itemCount == 1)
}
@Test("La quantité doit augmenter quand le même produit est ajouté deux fois")
mutating func addSameProductIncreasesQuantity() async throws {
mockRepository = MockCartRepository()
sut = CartViewModel(repository: mockRepository)
let product = Product(id: "p1", name: "MacBook", price: 75000)
await sut.addProduct(product)
await sut.addProduct(product)
#expect(sut.items.count == 1)
#expect(sut.items.first?.quantity == 2)
#expect(sut.totalPrice == 150000)
}
}
// Implémentation du mock
class MockCartRepository: CartRepositoryProtocol {
var savedItems: [CartItem] = []
func save(_ item: CartItem) async throws {
savedItems.append(item)
}
func fetchAll() async throws -> [CartItem] {
return savedItems
}
}// Swift - Exemples de tests d'intégration
import XCTest
@testable import MyApp
// Test d'intégration avec SQLite en mémoire
class UserRepositoryIntegrationTests: XCTestCase {
var repository: UserRepository!
var database: TestDatabase!
override func setUp() async throws {
// On utilise une vraie base de données SQLite en mémoire
database = try await TestDatabase.inMemory()
repository = UserRepository(database: database)
}
override func tearDown() async throws {
try await database.cleanup()
database = nil
repository = nil
}
func testCreateAndFetchUser() async throws {
// Création
let userId = try await repository.createUser(
name: "Ahmet Yılmaz",
email: "[email protected]"
)
// Récupération
let fetchedUser = try await repository.fetchUser(id: userId)
XCTAssertNotNil(fetchedUser)
XCTAssertEqual(fetchedUser?.name, "Ahmet Yılmaz")
XCTAssertEqual(fetchedUser?.email, "[email protected]")
}
func testDeleteUserCascadesToPosts() async throws {
let userId = try await repository.createUser(name: "Test", email: "[email protected]")
let postRepo = PostRepository(database: database)
_ = try await postRepo.createPost(title: "Post 1", userId: userId)
_ = try await postRepo.createPost(title: "Post 2", userId: userId)
// Supprimer l'utilisateur
try await repository.deleteUser(id: userId)
// Les posts de l'utilisateur doivent aussi être supprimés (cascade)
let posts = try await postRepo.fetchPosts(userId: userId)
XCTAssertTrue(posts.isEmpty, "La suppression de l'utilisateur doit aussi supprimer ses posts")
}
}
// Test d'intégration réseau avec une vraie URLSession
class APIClientIntegrationTests: XCTestCase {
func testFetchPublicAPIData() async throws {
// Ce test utilise une vraie connexion réseau
// Ne doit s'exécuter que dans des environnements CI avec accès réseau
try XCTSkipUnless(ProcessInfo.processInfo.environment["INTEGRATION_TESTS"] == "1")
let client = APIClient(baseURL: URL(string: "https://jsonplaceholder.typicode.com")!)
let posts: [Post] = try await client.request(path: "/posts")
XCTAssertFalse(posts.isEmpty)
XCTAssertEqual(posts.count, 100)
}
}Pyramide de tests : beaucoup de tests unitaires (70%), moins de tests d'intégration (20%), un minimum de tests UI/E2E (10%). Les tests unitaires offrent un retour rapide, les tests d'intégration valident les flux critiques — utilisez-les ensemble. La bonne stratégie n'est pas de remplacer l'un par l'autre, mais de les considérer comme complémentaires.
Obtenir une consultation gratuiteIl n'y a pas de chiffre magique. Une couverture de 80%+ de la logique métier est un bon objectif. Mais ce qui compte, ce n'est pas le pourcentage, c'est de tester les bonnes choses. Chercher aveuglément 100% de couverture peut être contre-productif.