class ModelTests { let container: ModelContainer init() throws { let config = ModelConfiguration(isStoredInMemoryOnly: true) container = tryModelContainer(for: User.self, configurations: config) } @Testfunc verifyBulkImport()throws { User.bulkImport() let modelContext = ModelContext(container) letcount = try modelContext.fetchCount(FetchDescriptor<User>()) #expect(count == 100) } }这段代码中,init 里创建了个内存中的 ModelContainer,然后测试方法里用它来检查导入功能。单看这个例子没啥问题,但实际项目中...咱们面临的情况可复杂多了。
@Test( .enabled(if: FeatureFlag.addition) ) func verifyAdd() { let result = add(1, 2) #expect(result == 3) }Scoping 更进一步 - 它允许我们在测试函数或测试套件执行前后插入自定义代码。有了它,就能创建可重用的测试环境设置,一次定义,到处使用,爽歪歪~
struct Environment { let search: (String) async throws -> [String] } extension Environment { privatestaticvar production: Environment { // 生产环境实现...(略过实现细节) } privatestaticvar mock: Environment { // 模拟环境...(这里可以返回假数据) } } extension Environment { @TaskLocalstaticvar current = Environment.production }这段代码定义了个 Environment 结构体存放应用依赖,用 TaskLocal 存当前环境。默认是生产环境,但测试中想用模拟环境。下面是重点,通过 Scoping 把环境切换到测试模式:
struct MockEnvironmentTrait: TestTrait, SuiteTrait, TestScoping { func provideScope( for test: Test, testCase: Test.Case?, performing function: @Sendable () async throws -> Void ) async throws { try await Environment.$current.withValue(Environment.mock) { try await function() } } } extension Trait where Self == MockEnvironmentTrait { static var mockedEnvironment: Self { Self() } }这段代码乍一看挺吓人,但实际上就干了一件事:在测试运行期间,把环境临时切换到模拟版本。
@Test(.mockedEnvironment) func verifySomething() async throws { // 这里的 Environment.current 自动是模拟环境 let results = try await Environment.current.search("query") #expect(!results.isEmpty) } @Suite(.mockedEnvironment) struct ExamplesTests { // 整个测试套件都用模拟环境,不用一遍遍设置 }只要加个 .mockedEnvironment 标记,测试就会在模拟环境中运行,完全不需要重复代码。感觉像是给测试加了个"魔法标签",标记一下就能改变整个测试的运行环境。
struct NetworkService { var fetch: (URL) async throws -> Data } extension NetworkService { staticlet live = NetworkService( fetch: { url in let (data, _) = try await URLSession.shared.data(from: url) return data } ) staticlet mock = NetworkService( fetch: { url in // 根据URL返回假数据 if url.absoluteString.contains("users") { returnData(""" [{"id": 1, "name": "小明"}] """.utf8) } returnData() } ) } // 存储当前服务 extension NetworkService { @TaskLocalstaticvar current = NetworkService.live }然后创建测试作用域:
struct MockNetworkTrait: TestTrait, SuiteTrait, TestScoping { func provideScope( for test: Test, testCase: Test.Case?, performing function: @Sendable () async throws -> Void ) async throws { try await NetworkService.$current.withValue(NetworkService.mock) { try await function() } } } extension Trait where Self == MockNetworkTrait { static var mockNetwork: Self { Self() } }使用起来超轻松:
@Test(.mockNetwork) func testUserFetching() async throws { let service = NetworkService.current // 自动是模拟服务! let userData = try await service.fetch(URL(string: "https://api.example.com/users")!) let users = try JSONDecoder().decode([User].self, from: userData) #expect(users.count == 1) #expect(users[0].name == "小明") }就一个 .mockNetwork 标记,测试中就能用模拟网络服务,连 NetworkService.mock 都不用写了,是不是很方便?
class NetworkTests { let mockService = NetworkService.mock func testUserFetching() async throws { let userData = try await mockService.fetch(URL(string: "https://api.example.com/users")!) let users = tryJSONDecoder().decode([User].self, from: userData) XCTAssertEqual(users.count, 1) XCTAssertEqual(users[0].name, "小明") } func testProductFetching() async throws { // 还得用同样的mock let userData = try await mockService.fetch(URL(string: "https://api.example.com/products")!) // 更多测试... } } // 另一个测试类要用同样的mock...又得重复一遍 class OrderTests { let mockService = NetworkService.mock // 又是一堆重复设置... }新方法:
@Suite(.mockNetwork) struct NetworkTests { func testUserFetching() async throws { let service = NetworkService.current // 自动有mock环境 let userData = try await service.fetch(URL(string: "https://api.example.com/users")!) let users = tryJSONDecoder().decode([User].self, from: userData) #expect(users.count == 1) #expect(users[0].name == "小明") } func testProductFetching() async throws { // 不用重复设置mock // 直接用... } } @Suite(.mockNetwork) struct OrderTests { // 同样不用重复设置,一个标记搞定 }新写法是不是干净多了?以后要改模拟服务的行为,只要改一个地方,不用翻遍整个测试目录去查找替换。我第一次用这种方式重构测试代码时,删掉了几百行重复的设置代码,那感觉...太爽了!
// 离线模式测试 struct OfflineModeTrait: TestTrait, SuiteTrait, TestScoping { func provideScope(for test: Test, testCase: Test.Case?, performing function: @Sendable () async throws -> Void) async throws { try await Network.$isOnline.withValue(false) { try await function() } } } // 错误响应测试 struct ErrorResponseTrait: TestTrait, SuiteTrait, TestScoping { let statusCode: Int func provideScope(for test: Test, testCase: Test.Case?, performing function: @Sendable () async throws -> Void) async throws { try await NetworkService.$current.withValue(NetworkService.withErrorStatus(statusCode)) { try await function() } } } // 组合使用,太灵活了 @Test(.mockNetwork, .offlineMode) func testOfflineBehavior() async throws { // 测试离线状态下的行为... } // 还可以带参数 @Test(.errorResponse(statusCode: 404)) func testNotFoundHandling() async throws { // 测试404错误处理... }这简直是测试各种边界情况的利器!我用这个方法模拟了网络超时、服务器错误、数据损坏等各种异常场景,测试覆盖率一下子提高了不少。
4.让测试代码专注于验证逻辑而非环境设置