• Swift 6.1中的新特性:一行代码解决的问题,你可能用了上百行在重复...
  • 发布于 2天前
  • 19 热度
    0 评论
  • 心已凉
  • 8 粉丝 52 篇博客
  •   
前言
Swift 6.1 虽然已经发布一段时间了,虽然表面上看大部分都是些外观调整,但我意外发现了个被很多人忽视的宝藏功能 — Swift Testing 框架中的 Scoping。本来只是随手翻了下更新日志,结果这个"小更新"直接让我眼前一亮!说句实话,我之前写测试代码时总是有种"啰嗦感",尤其是反复设置测试环境那块,简直烦人。昨天花了点时间尝试了这个新特性,立马感觉整个人都舒坦了 - 测试代码一下子干净了不少!今天就分享给大家这个提升幸福感的小技巧。

先说说以前怎么写测试
在聊 Scoping 之前,咱们得先看看 Swift Testing 的基础用法(可能有些同学还没用过这个框架,我简单说下)。Swift Testing 中,我们通常用类型的 init 和 deinit 方法来设置和清理测试环境,有点像传统单元测试框架里的 setup 和 teardown:
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,然后测试方法里用它来检查导入功能。单看这个例子没啥问题,但实际项目中...咱们面临的情况可复杂多了。

为啥需要 Scoping?痛点在哪?
想想看,假如你有十几个测试用例,都依赖相同的 ModelContainer 或预定义数据,你会咋办?我之前的做法是复制粘贴同样的初始化代码,写着写着就烦了。更惨的是,如果测试环境设置复杂,比如要模拟网络请求、设置一堆依赖项,这些重复代码简直让测试变得又臃肿又难维护。记得有次我修改了测试环境配置,结果要改十几个地方,改到怀疑人生...
这正是 Swift Testing 的 Scoping 功能解决的痛点!

Scoping:咋回事儿?咋用?
Scoping 其实是在测试特征(Testing Traits)基础上的扩展。你可能用过测试特征,它可以让我们标记或配置测试行为:
@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 都不用写了,是不是很方便?

旧方法 vs 新方法:差别咋这么大?
对比一下使用 Scoping 前后代码差异,这才叫一目了然:
旧方法:
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 {
    // 同样不用重复设置,一个标记搞定
}
新写法是不是干净多了?以后要改模拟服务的行为,只要改一个地方,不用翻遍整个测试目录去查找替换。我第一次用这种方式重构测试代码时,删掉了几百行重复的设置代码,那感觉...太爽了!

玩出花:组合多种测试环境
在我们实际项目中,经常需要测试不同的场景。有了 Scoping,可以轻松组合出各种测试环境:
// 离线模式测试
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错误处理...
}
这简直是测试各种边界情况的利器!我用这个方法模拟了网络超时、服务器错误、数据损坏等各种异常场景,测试覆盖率一下子提高了不少。

小结
说实话,Swift 6.1 的这个 Test Scoping 功能看着不起眼,我差点就错过了,没想到却是个解决测试重复代码的神器。它让我们能:
1.一次性创建可复用的测试环境
2.大量减少重复代码
3.轻松模拟各种应用状态

4.让测试代码专注于验证逻辑而非环境设置


最近改造了一个老项目的测试,用上这个特性后测试代码量减少了近三分之一,而且读起来清晰多了。如果你正在开发稍微复杂点的 Swift 应用,强烈建议试试这个"隐藏宝藏"。我以前总觉得写测试是件苦差事,但有了这种工具后,写测试的体验确实好了不少。Swift 团队这次虽然没大张旗鼓地宣传这个特性,但真的是给开发者带来了实实在在的便利。
用户评论