Claude-skill-registry ios-snapshot-test
install
source · Clone the upstream repo
git clone https://github.com/majiayu000/claude-skill-registry
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/majiayu000/claude-skill-registry "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/data/ios-snapshot-test" ~/.claude/skills/majiayu000-claude-skill-registry-ios-snapshot-test && rm -rf "$T"
manifest:
skills/data/ios-snapshot-test/SKILL.mdsource content
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での失敗時はアーティファクトを確認