📖

FlutterアプリをAppium × Azure Container Appsで完全自動化に挑戦!

に公開

🚀 FlutterアプリをDockerコンテナ化したAppium × Azure Container Appsで完全自動化!CDK for Terraformでインフラもコード化する最強パイプライン

こんにちは!
今回は、FlutterアプリでDockerコンテナ化したAppiumを使ったE2Eテストから、Azure Container Apps + CDK for Terraformでのインフラ構築、GitHub Actionsでの自動ビルド・デプロイまでの全工程を自動化する方法に挑戦してみたいと思います✨

「モバイルアプリのE2Eテストって面倒くさいよね」「UIテストの自動化できると労力相当減るよね」
「Appium環境の管理が大変...」「スケーラブルなテスト環境が欲しい...」「インフラもコード化したい...」
そんなあなたにぴったりの内容です!

まー、こんな記事を書きながらも老害プログラマーなおじさんが絡まない近くのチームの皆さん方は、目検と緻密な手作業で、Excelで台帳管理された冗長的かつ大量のテストケースを手作業で消化しているわけです
そんな人海戦術の作業も大事な時代もありましたが、これからは冗長的な作業を人海戦術で消化する時代から、冗長的で単調な作業はできる限り自動化を試みて、自動化の精度を上げつつ、エラー箇所や分別が困難なデータに絞り込んで人が目検で確認をする
そんな状態に持っていきたい願いを込めて、ふと思いついた構成でモバイルアプリのE2Eテストの自動化を試みてみます

これがうまく行ったら脆弱性診断とコードレビュー、コードインスペクション、テストケースの追加なんかの方法も体系立てて整理できたら良いなーと思っています

🎯 この記事で達成できること

  • DockerでAppium環境をコンテナ化
  • CDK for TerraformでAzure Container Appsを構築
  • スケーラブルなE2Eテスト環境の実現
  • GitHub Actionsで完全自動化されたCI/CDパイプライン
  • インフラのコード化による再現性の確保

🛠️ 必要なツール・環境

  • Flutter SDK
  • Docker Desktop
  • Azure CLI
  • Terraform
  • CDK for Terraform (cdktf)
  • Node.js
  • GitHub リポジトリ

🐳 1. AppiumのDockerコンテナ化

まずはAppium環境をDockerでコンテナ化しましょう!

Dockerfileの作成

docker/appium/Dockerfileを作成:

FROM appium/appium:latest

# 必要なツールとドライバーをインストール
USER root

# Android SDKのセットアップ
RUN apt-get update && apt-get install -y \
    wget \
    unzip \
    openjdk-11-jdk \
    && rm -rf /var/lib/apt/lists/*

ENV JAVA_HOME=/usr/lib/jvm/java-11-openjdk-amd64
ENV ANDROID_HOME=/opt/android-sdk
ENV PATH=$PATH:$ANDROID_HOME/tools:$ANDROID_HOME/platform-tools

# Android SDKをダウンロード
RUN wget -q https://dl.google.com/android/repository/commandlinetools-linux-8512546_latest.zip -O android-sdk.zip \
    && unzip -q android-sdk.zip -d $ANDROID_HOME \
    && rm android-sdk.zip

# Flutter ドライバーと必要なドライバーをインストール
RUN npm install -g appium@next
RUN appium driver install flutter
RUN appium driver install uiautomator2

# ポート設定
EXPOSE 4723

# Appiumサーバーを起動
CMD ["appium", "--address", "0.0.0.0", "--port", "4723", "--relaxed-security"]

Docker Composeファイル

docker-compose.ymlを作成:

version: '3.8'

services:
  appium:
    build:
      context: ./docker/appium
      dockerfile: Dockerfile
    ports:
      - "4723:4723"
    environment:
      - APPIUM_HOST=0.0.0.0
      - APPIUM_PORT=4723
    volumes:
      - ./test_results:/opt/test_results
    networks:
      - appium-network

  android-emulator:
    image: budtmo/docker-android:emulator_11.0
    privileged: true
    ports:
      - "6080:6080"  # noVNC
      - "5554:5554"  # ADB
      - "5555:5555"  # ADB
    environment:
      - EMULATOR_DEVICE=Samsung Galaxy S10
      - WEB_VNC=true
    networks:
      - appium-network

networks:
  appium-network:
    driver: bridge

🏗️ 2. CDK for TerraformでAzure Container Appsを構築

インフラをコード化しましょう!

CDKTFプロジェクトの初期化

# CDKTFをインストール
npm install -g cdktf-cli

# プロジェクト初期化
mkdir azure-infra && cd azure-infra
cdktf init --template=typescript --providers=azurerm

main.ts でAzure Container Appsを定義

import { Construct } from "constructs";
import { App, TerraformStack } from "cdktf";
import { AzurermProvider } from "@cdktf/provider-azurerm/lib/provider";
import { ResourceGroup } from "@cdktf/provider-azurerm/lib/resource-group";
import { ContainerApp } from "@cdktf/provider-azurerm/lib/container-app";
import { ContainerAppEnvironment } from "@cdktf/provider-azurerm/lib/container-app-environment";
import { LogAnalyticsWorkspace } from "@cdktf/provider-azurerm/lib/log-analytics-workspace";

class AppiumInfraStack extends TerraformStack {
  constructor(scope: Construct, id: string) {
    super(scope, id);

    // Azure プロバイダーの設定
    new AzurermProvider(this, "azurerm", {
      features: {},
    });

    // リソースグループ
    const rg = new ResourceGroup(this, "rg", {
      name: "rg-appium-e2e",
      location: "East US 2",
      tags: {
        environment: "test",
        project: "flutter-e2e"
      }
    });

    // Log Analytics Workspace
    const logWorkspace = new LogAnalyticsWorkspace(this, "log-workspace", {
      name: "log-appium-workspace",
      resourceGroupName: rg.name,
      location: rg.location,
      sku: "PerGB2018",
      retentionInDays: 30,
    });

    // Container App Environment
    const containerAppEnv = new ContainerAppEnvironment(this, "container-app-env", {
      name: "appium-environment",
      resourceGroupName: rg.name,
      location: rg.location,
      logAnalyticsWorkspaceId: logWorkspace.id,
      tags: {
        environment: "test"
      }
    });

    // Appium Container App
    const appiumApp = new ContainerApp(this, "appium-app", {
      name: "appium-server",
      resourceGroupName: rg.name,
      containerAppEnvironmentId: containerAppEnv.id,
      revisionMode: "Single",
      
      template: {
        container: [{
          name: "appium",
          image: "your-acr.azurecr.io/appium:latest",
          cpu: 1,
          memory: "2Gi",
          env: [{
            name: "APPIUM_HOST",
            value: "0.0.0.0"
          }, {
            name: "APPIUM_PORT",
            value: "4723"
          }]
        }],
        minReplicas: 1,
        maxReplicas: 3
      },

      ingress: {
        allowInsecureConnections: false,
        externalEnabled: true,
        targetPort: 4723,
        traffic: [{
          latestRevision: true,
          percentage: 100
        }]
      },

      tags: {
        component: "appium-server"
      }
    });

    // Android Emulator Container App
    new ContainerApp(this, "emulator-app", {
      name: "android-emulator",
      resourceGroupName: rg.name,
      containerAppEnvironmentId: containerAppEnv.id,
      revisionMode: "Single",
      
      template: {
        container: [{
          name: "emulator",
          image: "budtmo/docker-android:emulator_11.0",
          cpu: 2,
          memory: "4Gi",
          env: [{
            name: "EMULATOR_DEVICE",
            value: "Samsung Galaxy S10"
          }, {
            name: "WEB_VNC",
            value: "true"
          }]
        }],
        minReplicas: 1,
        maxReplicas: 2
      },

      ingress: {
        allowInsecureConnections: false,
        externalEnabled: true,
        targetPort: 6080,
        traffic: [{
          latestRevision: true,
          percentage: 100
        }]
      }
    });
  }
}

const app = new App();
new AppiumInfraStack(app, "appium-infra");
app.synth();

cdktf.json の設定

{
  "language": "typescript",
  "app": "npm run get && npx ts-node main.ts",
  "projectId": "flutter-appium-e2e",
  "sendCrashReports": "false",
  "terraformProviders": [
    "azurerm@~>3.0"
  ],
  "terraformModules": [],
  "codeMakerOutput": "generated",
  "context": {
    "excludeStackIdFromLogicalIds": "true",
    "allowSepCharsInLogicalIds": "true"
  }
}

⚙️ 3. GitHub Actionsでインフラとアプリを自動デプロイ

.github/workflows/infrastructure.ymlを作成:

name: Deploy Infrastructure and Appium

on:
  push:
    branches: [ main ]
    paths: 
      - 'azure-infra/**'
      - 'docker/**'

jobs:
  deploy-infrastructure:
    runs-on: ubuntu-latest
    
    steps:
    - uses: actions/checkout@v3
    
    - name: Setup Node.js
      uses: actions/setup-node@v3
      with:
        node-version: '18'
        
    - name: Setup Azure CLI
      uses: azure/login@v1
      with:
        creds: ${{ secrets.AZURE_CREDENTIALS }}
    
    - name: Install CDKTF CLI
      run: npm install -g cdktf-cli
      
    - name: Install dependencies
      working-directory: azure-infra
      run: npm install
      
    - name: Deploy Infrastructure
      working-directory: azure-infra
      run: |
        cdktf get
        cdktf deploy --auto-approve
      env:
        ARM_CLIENT_ID: ${{ secrets.ARM_CLIENT_ID }}
        ARM_CLIENT_SECRET: ${{ secrets.ARM_CLIENT_SECRET }}
        ARM_SUBSCRIPTION_ID: ${{ secrets.ARM_SUBSCRIPTION_ID }}
        ARM_TENANT_ID: ${{ secrets.ARM_TENANT_ID }}

  build-and-push-images:
    needs: deploy-infrastructure
    runs-on: ubuntu-latest
    
    steps:
    - uses: actions/checkout@v3
    
    - name: Azure Container Registry Login
      uses: azure/docker-login@v1
      with:
        login-server: ${{ secrets.ACR_LOGIN_SERVER }}
        username: ${{ secrets.ACR_USERNAME }}
        password: ${{ secrets.ACR_PASSWORD }}
    
    - name: Build and push Appium image
      run: |
        docker build -t ${{ secrets.ACR_LOGIN_SERVER }}/appium:${{ github.sha }} ./docker/appium
        docker push ${{ secrets.ACR_LOGIN_SERVER }}/appium:${{ github.sha }}
        docker tag ${{ secrets.ACR_LOGIN_SERVER }}/appium:${{ github.sha }} ${{ secrets.ACR_LOGIN_SERVER }}/appium:latest
        docker push ${{ secrets.ACR_LOGIN_SERVER }}/appium:latest

📱 4. 更新されたE2Eテストワークフロー

.github/workflows/e2e_tests.ymlを作成:

name: Flutter E2E Tests with Azure Container Apps

on:
  push:
    branches: [ main, develop ]
  pull_request:
    branches: [ main ]

jobs:
  e2e-tests:
    runs-on: ubuntu-latest
    
    steps:
    - uses: actions/checkout@v3
    
    - name: Setup Flutter
      uses: subosito/flutter-action@v2
      with:
        flutter-version: '3.10.0'
        
    - name: Install dependencies
      run: flutter pub get
      
    - name: Build test APK
      run: flutter build apk --debug --target=test_driver/app.dart
      
    - name: Wait for Appium service
      run: |
        echo "Waiting for Appium server to be ready..."
        timeout 300 bash -c 'until curl -f http://${{ secrets.APPIUM_SERVER_URL }}/wd/hub/status; do sleep 5; done'
      
    - name: Run E2E Tests
      run: |
        flutter drive \
          --driver=test_driver/app.dart \
          --target=test_driver/app_test.dart \
          --dart-define=APPIUM_SERVER_URL=${{ secrets.APPIUM_SERVER_URL }}
      env:
        APPIUM_SERVER_URL: ${{ secrets.APPIUM_SERVER_URL }}
        
    - name: Upload test results
      uses: actions/upload-artifact@v3
      if: always()
      with:
        name: test-results
        path: test_driver/screenshots/

  deploy:
    needs: e2e-tests
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'
    
    steps:
    - uses: actions/checkout@v3
    
    - name: Setup Flutter
      uses: subosito/flutter-action@v2
      with:
        flutter-version: '3.10.0'
    
    - name: Build release APK
      run: |
        flutter pub get
        flutter build apk --release
        
    - name: Deploy to Firebase App Distribution
      uses: wzieba/Firebase-Distribution-Github-Action@v1
      with:
        appId: ${{ secrets.FIREBASE_APP_ID }}
        serviceCredentialsFileContent: ${{ secrets.CREDENTIAL_FILE_CONTENT }}
        groups: testers
        file: build/app/outputs/flutter-apk/app-release.apk
        releaseNotes: "E2E tests passed ✅ Build: ${{ github.sha }}"

🔧 5. 更新されたテストコード

test_driver/app_test.dartを Azure Container Apps 用に更新:

import 'package:flutter_driver/flutter_driver.dart';
import 'package:test/test.dart';

void main() {
  group('Flutter E2E Tests with Azure Container Apps', () {
    late FlutterDriver driver;
    final String appiumUrl = const String.fromEnvironment(
      'APPIUM_SERVER_URL', 
      defaultValue: 'http://localhost:4723'
    );

    setUpAll(() async {
      // Azure Container Apps上のAppiumサーバーに接続
      driver = await FlutterDriver.connect(
        dartVmServiceUrl: '$appiumUrl/wd/hub',
        timeout: Duration(minutes: 2)
      );
    });

    tearDownAll(() async {
      driver?.close();
    });

    test('カウンターアプリの基本動作テスト', () async {
      // 初期状態の確認
      await driver.waitFor(find.text('0'), timeout: Duration(seconds: 10));
      
      // プラスボタンをタップ
      await driver.tap(find.byType('FloatingActionButton'));
      
      // カウントが増加したことを確認
      await driver.waitFor(find.text('1'), timeout: Duration(seconds: 5));
      
      // スクリーンショットを撮影
      await driver.screenshot().then((pixels) async {
        final file = File('test_driver/screenshots/counter_test.png');
        await file.writeAsBytes(pixels);
      });
    });

    test('複数回タップのストレステスト', () async {
      for (int i = 0; i < 5; i++) {
        await driver.tap(find.byType('FloatingActionButton'));
        await Future.delayed(Duration(milliseconds: 500));
      }
      
      await driver.waitFor(find.text('6'), timeout: Duration(seconds: 10));
    });
  });
}

📊 6. 監視とスケーリング設定

Azure Monitor アラートの設定

CDKTFにアラート設定を追加:

import { MonitorMetricAlert } from "@cdktf/provider-azurerm/lib/monitor-metric-alert";

// CPU使用率アラート
new MonitorMetricAlert(this, "cpu-alert", {
  name: "appium-high-cpu",
  resourceGroupName: rg.name,
  scopes: [appiumApp.id],
  criteria: [{
    metricNamespace: "Microsoft.App/containerApps",
    metricName: "CpuPercentage",
    aggregation: "Average",
    operator: "GreaterThan",
    threshold: 80
  }],
  frequency: "PT1M",
  windowSize: "PT5M",
  severity: 2,
  description: "Appium server high CPU usage"
});

自動スケーリングルールの追加

// Container Appの更新でスケーリングルールを追加
template: {
  container: [/* existing config */],
  minReplicas: 1,
  maxReplicas: 5,
  scale: [{
    minReplicas: 1,
    maxReplicas: 5,
    rules: [{
      name: "cpu-scaling",
      custom: {
        type: "cpu",
        metadata: {
          type: "Utilization",
          value: "70"
        }
      }
    }]
  }]
}

💰 7. コスト最適化

スケジュールベースのスケーリング

// 夜間はレプリカ数を0に
template: {
  scale: [{
    rules: [{
      name: "schedule-scaling",
      custom: {
        type: "cron",
        metadata: {
          timezone: "Asia/Tokyo",
          start: "0 9 * * 1-5",  // 平日9時開始
          end: "0 18 * * 1-5",   // 平日18時終了
          desiredReplicas: "2"
        }
      }
    }]
  }]
}

🔐 8. セキュリティ強化

Key Vaultとの統合

import { KeyVault } from "@cdktf/provider-azurerm/lib/key-vault";
import { KeyVaultSecret } from "@cdktf/provider-azurerm/lib/key-vault-secret";

const keyVault = new KeyVault(this, "key-vault", {
  name: "kv-appium-secrets",
  resourceGroupName: rg.name,
  location: rg.location,
  tenantId: "your-tenant-id",
  skuName: "standard"
});

new KeyVaultSecret(this, "appium-config", {
  name: "appium-configuration",
  keyVaultId: keyVault.id,
  value: JSON.stringify({
    serverUrl: "https://your-appium-server.com",
    capabilities: { /* ... */ }
  })
});

🎉 9. 完成!運用のベストプラクティス

デプロイメント戦略

# ブルー/グリーンデプロイメント
cdktf deploy --var="deployment_slot=green"

# カナリアリリース
cdktf deploy --var="traffic_percentage=10"

災害復旧

// マルチリージョン構成
const primaryRg = new ResourceGroup(this, "primary-rg", {
  name: "rg-appium-primary",
  location: "East US 2"
});

const secondaryRg = new ResourceGroup(this, "secondary-rg", {
  name: "rg-appium-secondary", 
  location: "West US 2"
});

📈 10. パフォーマンス監視

カスタムメトリクスの追加

# GitHub Actions にメトリクス送信を追加
- name: Send metrics to Azure Monitor
  run: |
    az monitor metrics emit \
      --resource ${{ secrets.CONTAINER_APP_ID }} \
      --namespace "Custom/E2ETests" \
      --metric "TestDuration" \
      --value $TEST_DURATION \
      --dimension "TestSuite=smoke"

💡 まとめ

Azure Container Apps + CDK for Terraformを使った最強のE2E自動化パイプラインが完成しました!🎊

得られるメリット:

  • 🐳 コンテナ化: 一貫した実行環境
  • ☁️ クラウドネイティブ: スケーラブルで高可用性
  • 🏗️ Infrastructure as Code: 再現可能なインフラ
  • 🔄 自動スケーリング: コスト効率的な運用
  • 📊 包括的な監視: 問題の早期発見
  • 🔐 エンタープライズレベルのセキュリティ

この構成により、開発チーム全体の生産性が飛躍的に向上し、高品質なアプリリリースが実現できます!

質問やフィードバックがあれば、コメントでお気軽にどうぞ〜 ✨


関連記事:

#Flutter #Azure #ContainerApps #CDKTerraform #Docker #Appium #E2Eテスト #InfrastructureAsCode #CloudNative

Discussion