はじめに
SwiftのテストでNimbleを使っているのですが
React(Vitest)と比べてアサーションの書き方が少し面倒に感じ
リファクタしてみたので記事にしようと思います
Vitest と Nimble の比較
React (Vitest) の場合
// プリミティブ型
expect("hoge").toEqual("hoge") // もしくは toBe()
expect(10000).toEqual(10000) // もしくは toBe()
// Boolean型
expect(true).toBeTruthy()
expect(false).toBeFalsy()
// 空かどうか
expect("").toBeEmpty()
// Null型
expect(null).toBeNull()
Nimble の場合
// プリミティブ型
expect("hoge").to(equal("hoge"))
expect(10000).to(equal(10000))
// Boolean型
expect(true).to(beTrue())
expect(false).to(beFalse())
// 空かどうか
expect("").to(beEmpty())
// Nil型
expect(null).to(beNil())
両者の決定的な違い
メソッド | ネスト数 | |
---|---|---|
Vitest | .toEqual() |
0 |
Nimble | .to(equal()) |
1 |
Nimbleは「.to(XXX)
で ネストしなきゃいけない」
これが、塵も積もると結構めんどいんですよね。
これを解消して、Vitestライクに書きたい。
これが今回のモチベーションです
リファクタしよう
何はともあれ、まずはコード
以下のような拡張関数を作ります
@testable import my_app
import Nimble
/// for Primitive
extension SyncExpectation where Value: Equatable {
// 同期関数用
@discardableResult
func toEqual(_ expectedValue: Value?) -> Self {
return to(equal(expectedValue))
}
// 同期関数用(内部的に待つ)
@discardableResult
func toEventuallyEqual(_ expectedValue: Value?) -> Self {
return toEventually(equal(expectedValue))
}
// 非同期関数用
@discardableResult
func toEventuallyEqual(_ expectedValue: Value?) async -> Self {
return await toEventually(equal(expectedValue))
}
}
/// for Boolean
extension SyncExpectation where Value == Bool {
// 同期関数用
@discardableResult
func toBeTruthy() -> Self {
return to(beTrue())
}
// 同期関数用(内部的に待つ)
@discardableResult
func toEventuallyTruthy() -> Self {
return toEventually(beTrue())
}
// 非同期関数用
@discardableResult
func toEventuallyTruthy() async -> Self {
return await toEventually(beTrue())
}
// 同期関数用
@discardableResult
func toBeFalsy() -> Self {
return to(beFalse())
}
// 同期関数用(内部的に待つ)
@discardableResult
func toEventuallyFalsy() -> Self {
return toEventually(beFalse())
}
// 非同期関数用
@discardableResult
func toEventuallyFalsy() async -> Self {
return await toEventually(beFalse())
}
}
/// for List
extension SyncExpectation where Value == [Any] {
// 同期関数用
@discardableResult
func toBeEmpty() -> Self {
return to(beEmpty())
}
// 同期関数用(内部的に待つ)
@discardableResult
func toEventuallyEmpty() -> Self {
return toEventually(beEmpty())
}
// 非同期関数用
@discardableResult
func toEventuallyEmpty() async -> Self {
return await toEventually(beEmpty())
}
}
/// for Nil
extension SyncExpectation where Value: Equatable {
// 同期関数用
@discardableResult
func toBeNil() -> Self {
return to(beNil())
}
// 同期関数用(内部的に待つ)
@discardableResult
func toEventuallyNil() -> Self {
return toEventually(beNil())
}
// 非同期関数用
@discardableResult
func toEventuallyNil() async -> Self {
return await toEventually(beNil())
}
}
プリミティブ型、Boolean型、配列型、Nil型それぞれに対して
NimbleのSyncExpectation
を拡張します。
それぞれ、以下のようなテスト関数のパターンに適用できるようにしています。
- 同期関数の場合
- 同期関数だけど
eventually
で何かしらの処理を待ちたい場合 - 非同期関数の場合
SyncExpectation
について
SyncExpectation
は、Nimbleの expect()
メソッドが返すstructの型です。
ジェネリクス<T>
で型を一つ受け取り、それに応じてアサーションが実行されます。
要は、expect(XXX)
渡されたXXXによって型が決まります。
定義は以下のようになっています。
public struct SyncExpectation<Value>: Expectation {
public let expression: Expression<Value>
@discardableResult
public func to(_ matcher: Matcher<Value>, description: String? = nil) -> Self {
let (pass, msg) = execute(
expression,
.toMatch,
matcher,
to: "to",
description: description
)
return verify(pass, msg)
}
}
func expect<T>(
fileID: String = #fileID,
file: FileString = #filePath,
line: UInt = #line,
column: UInt = #column,
_ expression: @autoclosure @escaping () throws -> T?
) -> SyncExpectation<T>
使い方
プリミティブ型
func getHoge() -> String { return "hoge" }
func test_hogeは必ずhogeであること() {
let actualHoge: String = getHoge()
/// 従来の書き方
expect(actualHoge).to(equal("hoge"))
/// リファクタ後の書き方
expect(actualHoge).toEqual("hoge") // ✅ pass!!
expect(actualHoge).toEventuallyEqual("hoge") // ✅ pass!!
expect(actualHoge).toEqual("fuga") // ❌ fail...
expect(actualHoge).toEventuallyEqual("fuga") // ❌ fail...
}
Boolean型
func getTrue() -> Bool { return true }
func test_真実はいつも1つであること() {
let actualTrue: Bool = getTrue()
/// 従来の書き方
expect(actualTrue).to(beTrue())
/// リファクタ後の書き方
expect(actualTrue).toBeTruthy() // ✅ passed!!
expect(actualTrue).toEventuallyTruthy() // ✅ passed!!
expect(actualTrue).toBeFalsy() // ❌ fail...
expect(actualTrue).toEventuallyFalsy() // ❌ fail...
}
List型
func getEmptyList() -> [String] { return [] }
func test_配列は空であること() {
let actualEmptyList: [String] = getEmptyList()
/// 従来の書き方
expect(actualEmptyList).to(beEmpty())
/// リファクタ後の書き方
expect(actualEmptyList).toBeEmpty() // ✅ passed!!
expect(actualEmptyList).toEventuallyEmpty() // ✅ passed!!
expect(["hoge"]).toBeEmpty() // ❌ fail...
expect(["hoge"]).toEventuallyEmpty() // ❌ fail...
}
Nil型
func getNil() -> String? { return nil }
func test_nilはnilであること() {
let actualNillableValue: String? = getNil()
/// 従来の書き方
expect(actualNillableValue).to(beNil())
/// リファクタ後の書き方
expect(actualNillableValue).toBeNil() // ✅ passed!!
expect(actualNillableValue).toEventuallyNil() // ✅ passed!!
expect("not nill value").toBeNil() // ❌ fail...
expect("not nil value").toEventuallyNil() // ❌ fail...
}
非同期関数の場合(プリミティブの場合を例として挙げます)
func getHoge() async -> String { return "hoge" }
func test_非同期でもhogeは必ずhogeであること() async {
let actualHoge: String = await getHoge()
await expect(actualHoge).toEventuallyEqual("hoge") // ✅ passed!!
await expect(actualHoge).toEventuallyEqual("fuga") // ❌ fail...
}
おわりに
かなりReactっぽく、且つネストすることなく
アサーションを書けるようになりました。
これで、言語・OSレベルでのコードの違いが解消され、ストレスが減りました!
今回実装し忘れましたが、.notTo()
のパターンも拡張しておくと便利かもしれませんね。
気が向いたら更新します。