| name | ios-snapshot-test |
| description | スナップショットテスト支援。swift-snapshot-testing、UI変更検出。 使用タイミング: (1) UIコンポーネントのリグレッションテスト、(2) デザインシステムの検証、 (3) 複数デバイス・ダークモード対応の確認、(4) UIリファクタリング時の安全性確保 |
iOS スナップショットテスト支援スキル
swift-snapshot-testingを使用したUIスナップショットテストをガイドする。
swift-snapshot-testing
導入
// Package.swift
dependencies: [
.package(url: "https://github.com/pointfreeco/swift-snapshot-testing", from: "1.15.0")
]
// テストターゲットに追加
.testTarget(
name: "MyAppTests",
dependencies: [
.product(name: "SnapshotTesting", package: "swift-snapshot-testing")
]
)
基本的なテスト
import XCTest
import SnapshotTesting
@testable import MyApp
final class ProfileViewSnapshotTests: XCTestCase {
// 記録モードを有効にして初回スナップショット生成
// isRecording = true
func testProfileView() {
let view = ProfileView(user: .mock)
assertSnapshot(of: view, as: .image)
}
func testProfileView_darkMode() {
let view = ProfileView(user: .mock)
assertSnapshot(of: view, as: .image(traits: .init(userInterfaceStyle: .dark)))
}
func testProfileView_largeText() {
let view = ProfileView(user: .mock)
assertSnapshot(
of: view,
as: .image(traits: .init(preferredContentSizeCategory: .accessibilityLarge))
)
}
}
SwiftUIビューのテスト
ホスティングコントローラー経由
import SwiftUI
import SnapshotTesting
final class SwiftUISnapshotTests: XCTestCase {
func testContentView() {
let view = ContentView()
let controller = UIHostingController(rootView: view)
// サイズを指定
controller.view.frame = CGRect(x: 0, y: 0, width: 375, height: 812)
assertSnapshot(of: controller, as: .image)
}
func testButtonStyles() {
let view = VStack(spacing: 16) {
Button("Primary") {}
.buttonStyle(PrimaryButtonStyle())
Button("Secondary") {}
.buttonStyle(SecondaryButtonStyle())
Button("Destructive") {}
.buttonStyle(DestructiveButtonStyle())
}
.padding()
let controller = UIHostingController(rootView: view)
controller.view.frame = CGRect(x: 0, y: 0, width: 300, height: 200)
assertSnapshot(of: controller, as: .image)
}
}
カスタムスナップショット戦略
extension Snapshotting where Value: SwiftUI.View, Format == UIImage {
static func swiftUIImage(
drawHierarchyInKeyWindow: Bool = false,
precision: Float = 1,
perceptualPrecision: Float = 1,
size: CGSize? = nil,
traits: UITraitCollection = .init()
) -> Snapshotting {
return Snapshotting<UIViewController, UIImage>.image(
drawHierarchyInKeyWindow: drawHierarchyInKeyWindow,
precision: precision,
perceptualPrecision: perceptualPrecision,
size: size,
traits: traits
).pullback { view in
UIHostingController(rootView: view)
}
}
}
// 使用例
func testCustomStrategy() {
let view = MyCustomView()
assertSnapshot(
of: view,
as: .swiftUIImage(size: CGSize(width: 320, height: 480))
)
}
複数デバイス対応
デバイス設定
struct SnapshotDevice {
let name: String
let size: CGSize
let traits: UITraitCollection
static let iPhone15Pro = SnapshotDevice(
name: "iPhone15Pro",
size: CGSize(width: 393, height: 852),
traits: .init(userInterfaceIdiom: .phone)
)
static let iPhone15ProMax = SnapshotDevice(
name: "iPhone15ProMax",
size: CGSize(width: 430, height: 932),
traits: .init(userInterfaceIdiom: .phone)
)
static let iPhoneSE = SnapshotDevice(
name: "iPhoneSE",
size: CGSize(width: 375, height: 667),
traits: .init(userInterfaceIdiom: .phone)
)
static let iPadPro12_9 = SnapshotDevice(
name: "iPadPro12_9",
size: CGSize(width: 1024, height: 1366),
traits: .init(userInterfaceIdiom: .pad)
)
static let all: [SnapshotDevice] = [
.iPhone15Pro, .iPhone15ProMax, .iPhoneSE, .iPadPro12_9
]
}
マトリックステスト
final class MultiDeviceSnapshotTests: XCTestCase {
func testHomeScreen_allDevices() {
let view = HomeScreen(viewModel: .mock)
for device in SnapshotDevice.all {
let controller = UIHostingController(rootView: view)
controller.view.frame = CGRect(origin: .zero, size: device.size)
assertSnapshot(
of: controller,
as: .image(traits: device.traits),
named: device.name
)
}
}
func testHomeScreen_lightAndDark() {
let view = HomeScreen(viewModel: .mock)
let device = SnapshotDevice.iPhone15Pro
for style in [UIUserInterfaceStyle.light, .dark] {
let traits = UITraitCollection(traitsFrom: [
device.traits,
UITraitCollection(userInterfaceStyle: style)
])
let controller = UIHostingController(rootView: view)
controller.view.frame = CGRect(origin: .zero, size: device.size)
assertSnapshot(
of: controller,
as: .image(traits: traits),
named: style == .light ? "light" : "dark"
)
}
}
}
状態別テスト
ローディング・エラー状態
final class StateSnapshotTests: XCTestCase {
func testUserList_loading() {
let view = UserListView(state: .loading)
assertSnapshot(of: view, as: .swiftUIImage())
}
func testUserList_loaded() {
let view = UserListView(state: .loaded(users: User.mockList))
assertSnapshot(of: view, as: .swiftUIImage())
}
func testUserList_empty() {
let view = UserListView(state: .loaded(users: []))
assertSnapshot(of: view, as: .swiftUIImage())
}
func testUserList_error() {
let view = UserListView(state: .error(message: "Network connection failed"))
assertSnapshot(of: view, as: .swiftUIImage())
}
}
アニメーション特定フレーム
func testProgressAnimation_midway() {
let view = CircularProgressView(progress: 0.5)
assertSnapshot(of: view, as: .swiftUIImage(), named: "50percent")
}
func testProgressAnimation_complete() {
let view = CircularProgressView(progress: 1.0)
assertSnapshot(of: view, as: .swiftUIImage(), named: "complete")
}
CI/CD統合
スナップショットの管理
MyAppTests/
├── __Snapshots__/
│ ├── ProfileViewSnapshotTests/
│ │ ├── testProfileView.1.png
│ │ ├── testProfileView_darkMode.1.png
│ │ └── testProfileView_largeText.1.png
│ └── HomeScreenSnapshotTests/
│ ├── testHomeScreen_iPhone15Pro.1.png
│ └── testHomeScreen_iPadPro12_9.1.png
.gitattributes設定
# スナップショット画像をLFSで管理
*/__Snapshots__/**/*.png filter=lfs diff=lfs merge=lfs -text
GitHub Actions
name: Snapshot Tests
on:
pull_request:
paths:
- '**/*.swift'
- '**/Assets.xcassets/**'
jobs:
snapshot-test:
runs-on: macos-14
steps:
- uses: actions/checkout@v4
with:
lfs: true
- name: Select Xcode
run: sudo xcode-select -s /Applications/Xcode_15.2.app
- name: Run Snapshot Tests
run: |
xcodebuild test \
-workspace MyApp.xcworkspace \
-scheme MyAppTests \
-destination 'platform=iOS Simulator,name=iPhone 15 Pro'
- name: Upload Failed Snapshots
if: failure()
uses: actions/upload-artifact@v4
with:
name: failed-snapshots
path: |
**/Failures/**
**/__Snapshots__/**
差分の許容
perceptualPrecision
// わずかなアンチエイリアスの差異を許容
assertSnapshot(
of: view,
as: .image(perceptualPrecision: 0.98) // 98%一致で合格
)
// より厳密なチェック
assertSnapshot(
of: view,
as: .image(precision: 1.0, perceptualPrecision: 1.0)
)
動的コンテンツのマスキング
extension UIView {
func maskDynamicContent() -> UIView {
// 日時表示などをマスク
subviews.filter { $0.accessibilityIdentifier == "timestamp" }
.forEach { $0.isHidden = true }
return self
}
}
func testFeed_maskTimestamps() {
let view = FeedView(posts: Post.mockList)
let controller = UIHostingController(rootView: view)
// タイムスタンプをマスクしてスナップショット
assertSnapshot(
of: controller.view.maskDynamicContent(),
as: .image
)
}
設計システム検証
コンポーネントカタログ
final class DesignSystemSnapshotTests: XCTestCase {
func testColorPalette() {
let view = VStack(spacing: 8) {
ForEach(ColorToken.allCases, id: \.self) { token in
HStack {
Rectangle()
.fill(token.color)
.frame(width: 60, height: 40)
Text(token.rawValue)
.font(.caption)
Spacer()
}
}
}
.padding()
assertSnapshot(of: view, as: .swiftUIImage(size: CGSize(width: 300, height: 600)))
}
func testTypography() {
let view = VStack(alignment: .leading, spacing: 12) {
Text("Title Large").font(.largeTitle)
Text("Title").font(.title)
Text("Title 2").font(.title2)
Text("Title 3").font(.title3)
Text("Headline").font(.headline)
Text("Body").font(.body)
Text("Callout").font(.callout)
Text("Subheadline").font(.subheadline)
Text("Footnote").font(.footnote)
Text("Caption").font(.caption)
Text("Caption 2").font(.caption2)
}
.padding()
assertSnapshot(of: view, as: .swiftUIImage())
}
func testIconLibrary() {
let icons = ["house", "gear", "person", "bell", "heart", "star"]
let view = LazyVGrid(columns: [GridItem(.adaptive(minimum: 60))], spacing: 16) {
ForEach(icons, id: \.self) { icon in
VStack {
Image(systemName: icon)
.font(.title)
Text(icon)
.font(.caption2)
}
}
}
.padding()
assertSnapshot(of: view, as: .swiftUIImage(size: CGSize(width: 300, height: 200)))
}
}
ベストプラクティス
命名規則
// ファイル名: {ViewName}SnapshotTests.swift
// テスト名: test{ViewName}_{State}_{Device}_{Theme}
func testProfileView_editing_iPhone15Pro_dark() { }
func testProfileView_viewing_iPadPro_light() { }
テストの構造化
final class ProfileViewSnapshotTests: XCTestCase {
// MARK: - Default State
func testDefaultState() {
assertSnapshot(of: makeView(), as: .swiftUIImage())
}
// MARK: - User Interaction States
func testEditingState() {
assertSnapshot(of: makeView(isEditing: true), as: .swiftUIImage())
}
// MARK: - Device Variations
func testOnSmallDevice() {
assertSnapshot(of: makeView(), as: .swiftUIImage(size: SnapshotDevice.iPhoneSE.size))
}
// MARK: - Accessibility
func testLargeText() {
assertSnapshot(
of: makeView(),
as: .swiftUIImage(traits: .init(preferredContentSizeCategory: .accessibilityLarge))
)
}
// MARK: - Helpers
private func makeView(isEditing: Bool = false) -> some View {
ProfileView(user: .mock, isEditing: isEditing)
}
}
チェックリスト
導入時
- swift-snapshot-testingをSPMで追加
- スナップショットディレクトリをGit管理に含める
- .gitattributesでLFS設定(必要に応じて)
テスト作成時
- 重要なUI状態をカバー(ローディング、エラー、空、データあり)
- ダークモード対応を確認
- 主要デバイスサイズでテスト
- アクセシビリティ設定でテスト
メンテナンス時
- 意図的なUI変更時は
isRecording = trueで再生成 - 差分が出た場合は変更が意図的かレビュー
- CIでの失敗時はアーティファクトを確認