Claude Code Plugins

Community-maintained marketplace

Feedback
0
0

|

Install Skill

1Download skill
2Enable skills in Claude

Open claude.ai/settings/capabilities and find the "Skills" section

3Upload to Claude

Click "Upload skill" and select the downloaded ZIP file

Note: Please verify skill by going through its instructions before using it.

SKILL.md

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での失敗時はアーティファクトを確認