mcpField
D
dotnet-maui-testing

dotnet-maui-testing

github.com/wshaddixテスト・品質8675 回インストール35 週間前に更新
リポジトリ

dotnet-maui-testing について

.NET MAUI アプリ向けのテストスキルです。Appium による UI 自動化と XHarness によるクロスプラットフォーム実行を組み合わせ、デバイスやエミュレーターでのテスト、要素の特定、プラットフォーム固有の動作検証を扱います。

主な機能

  1. 01Appium による UI 自動化
  2. 02XHarness によるクロスプラットフォーム実行
  3. 03AutomationId による要素特定
  4. 04プラットフォーム固有の動作テスト
  5. 05デバイスとエミュレーターのテスト手順

活用シーン

  1. 01デバイス上での MAUI アプリ操作の自動化
  2. 02プラットフォーム固有の UI 動作の検証
  3. 03エミュレーターやシミュレーターでのモバイルテスト実行

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/wshaddix/dotnet-skills --skill dotnet-maui-testing

インストール後、Claude Code で/dotnet-maui-testingを入力して読み込まれたか確認します。

詳細

dotnet-maui-testing

Appium を使用した UI 自動化と、XHarness を使用したクロスプラットフォームテスト実行による .NET MAUI アプリケーションのテストです。デバイスおよびエミュレーターでのテスト、プラットフォーム固有の動作検証、MAUI コントロールの要素検索方法、モバイル/デスクトップアプリ向けのテストインフラストラクチャを扱います。

バージョンの前提: .NET 8.0 以降を基準とし、Android 用の UIAutomator2 ドライバーおよび iOS 用の XCUITest ドライバーを備えた Appium 2.x、XHarness 1.x を使用します。例では、最新の Appium .NET クライアント(5.x 以降)を使用します。

対象外: ページオブジェクトモデルや待機方法など、共通の UI テストパターンについては [skill:dotnet-ui-testing-core] を参照してください。ブラウザーを使用したテストについては [skill:dotnet-playwright] で扱います。テストプロジェクトのひな形作成は [skill:dotnet-add-testing] が担当します。

前提条件: [skill:dotnet-add-testing] を使用して MAUI テストプロジェクトのひな形を作成していること。Appium サーバーをインストールしていること(npm install -g appium)。Android の場合は、エミュレーターを設定した Android SDK が必要です。iOS の場合は、シミュレーターを備えた Xcode が必要です(macOS のみ)。Windows の場合は、WinAppDriver をインストールしている必要があります。

相互参照:[skill:dotnet-ui-testing-core](ページオブジェクトモデル、テストセレクター、非同期の待機パターン)、[skill:dotnet-xunit](xUnit のフィクスチャとテスト構成)、[skill:dotnet-maui-development](MAUI プロジェクトの構造、XAML/MVVM パターン、プラットフォームサービス)、[skill:dotnet-maui-aot](iOS/Mac Catalyst での Native AOT と AOT ビルドのテストに関する考慮事項)。


MAUI 向けの Appium セットアップ

パッケージ
<PackageReference Include="Appium.WebDriver" Version="5.*" />
<PackageReference Include="xunit.v3" Version="3.2.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5" />
ドライバーの初期化
public class AppiumFixture : IAsyncLifetime
{
    public AppiumDriver Driver { get; private set; } = null!;

    public ValueTask InitializeAsync()
    {
        var options = new AppiumOptions();

        if (OperatingSystem.IsAndroid() || TestConfig.TargetPlatform == "Android")
        {
            options.PlatformName = "Android";
            options.AutomationName = "UiAutomator2";
            options.App = TestConfig.AndroidApkPath;
            options.AddAdditionalAppiumOption("deviceName", "Pixel_7_API_34");
            options.AddAdditionalAppiumOption("avd", "Pixel_7_API_34");
        }
        else if (OperatingSystem.IsIOS() || TestConfig.TargetPlatform == "iOS")
        {
            options.PlatformName = "iOS";
            options.AutomationName = "XCUITest";
            options.App = TestConfig.iOSAppPath;
            options.AddAdditionalAppiumOption("deviceName", "iPhone 15");
            options.AddAdditionalAppiumOption("platformVersion", "17.2");
        }
        else if (OperatingSystem.IsWindows() || TestConfig.TargetPlatform == "Windows")
        {
            options.PlatformName = "Windows";
            options.AutomationName = "Windows";
            options.App = TestConfig.WindowsAppPath;
        }

        Driver = new AppiumDriver(
            new Uri("http://localhost:4723"), options);

        // Explicit waits only -- do not set ImplicitWait (it causes
        // additive timeout behavior when combined with WebDriverWait)
        Driver.Manage().Timeouts().ImplicitWait = TimeSpan.Zero;

        return ValueTask.CompletedTask;
    }

    public ValueTask DisposeAsync()
    {
        Driver?.Quit();
        return ValueTask.CompletedTask;
    }
}
テスト構成
public static class TestConfig
{
    // Set via environment variables or test runsettings
    public static string TargetPlatform =>
        Environment.GetEnvironmentVariable("TEST_PLATFORM") ?? "Android";

    public static string AndroidApkPath =>
        Environment.GetEnvironmentVariable("ANDROID_APK_PATH")
        ?? Path.Combine(SolutionDir, "bin", "Release", "net8.0-android", "com.myapp-Signed.apk");

    public static string iOSAppPath =>
        Environment.GetEnvironmentVariable("IOS_APP_PATH")
        ?? Path.Combine(SolutionDir, "bin", "Release", "net8.0-ios", "MyApp.app");

    public static string WindowsAppPath =>
        Environment.GetEnvironmentVariable("WINDOWS_APP_PATH")
        ?? "com.mycompany.myapp_1.0.0.0_x64__9a0dh7ch11qe4!App";

    private static string SolutionDir =>
        Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", ".."));
}

AutomationId を使用した要素の検索

MAUI の AutomationId プロパティは、プラットフォームネイティブのアクセシビリティ識別子にマッピングされます。これは、クロスプラットフォームテストで最も信頼性の高いセレクターです。

XAML での AutomationId の設定
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml">

    <VerticalStackLayout>
        <Entry AutomationId="username-input"
               Placeholder="Username" />

        <Entry AutomationId="password-input"
               Placeholder="Password"
               IsPassword="True" />

        <Button AutomationId="login-button"
                Text="Log In"
                Clicked="OnLoginClicked" />

        <Label AutomationId="error-message"
               TextColor="Red" />
    </VerticalStackLayout>
</ContentPage>
テストでの要素の検索
public class LoginTests : IClassFixture<AppiumFixture>
{
    private readonly AppiumDriver _driver;

    public LoginTests(AppiumFixture fixture)
    {
        _driver = fixture.Driver;
    }

    [Fact]
    public void Login_ValidCredentials_NavigatesToHome()
    {
        // Find by AutomationId (maps to accessibility ID on each platform)
        var usernameField = _driver.FindElement(MobileBy.AccessibilityId("username-input"));
        var passwordField = _driver.FindElement(MobileBy.AccessibilityId("password-input"));
        var loginButton = _driver.FindElement(MobileBy.AccessibilityId("login-button"));

        usernameField.Clear();
        usernameField.SendKeys("testuser");
        passwordField.Clear();
        passwordField.SendKeys("P@ssw0rd!");
        loginButton.Click();

        // Wait for navigation
        var wait = new WebDriverWait(_driver, TimeSpan.FromSeconds(10));
        var homeTitle = wait.Until(d =>
            d.FindElement(MobileBy.AccessibilityId("home-title")));

        Assert.Equal("Welcome", homeTitle.Text);
    }

    [Fact]
    public void Login_InvalidCredentials_ShowsError()
    {
        var usernameField = _driver.FindElement(MobileBy.AccessibilityId("username-input"));
        var passwordField = _driver.FindElement(MobileBy.AccessibilityId("password-input"));
        var loginButton = _driver.FindElement(MobileBy.AccessibilityId("login-button"));

        usernameField.Clear();
        usernameField.SendKeys("wrong");
        passwordField.Clear();
        passwordField.SendKeys("wrong");
        loginButton.Click();

        var wait = new WebDriverWait(_driver, TimeSpan.FromSeconds(5));
        var errorLabel = wait.Until(d =>
            d.FindElement(MobileBy.AccessibilityId("error-message")));

        Assert.Contains("Invalid", errorLabel.Text);
    }
}

MAUI のページオブジェクトモデル

Appium のドライバーでページオブジェクトモデルパターンを適用します([skill:dotnet-ui-testing-core] を参照)。

public class LoginPage
{
    private readonly AppiumDriver _driver;
    private readonly WebDriverWait _wait;

    public LoginPage(AppiumDriver driver)
    {
        _driver = driver;
        _wait = new WebDriverWait(driver, TimeSpan.FromSeconds(10));
        WaitForPageLoaded();
    }

    private AppiumElement UsernameField =>
        _driver.FindElement(MobileBy.AccessibilityId("username-input"));
    private AppiumElement PasswordField =>
        _driver.FindElement(MobileBy.AccessibilityId("password-input"));
    private AppiumElement LoginButton =>
        _driver.FindElement(MobileBy.AccessibilityId("login-button"));
    private AppiumElement ErrorMessage =>
        _driver.FindElement(MobileBy.AccessibilityId("error-message"));

    public HomePage Login(string username, string password)
    {
        UsernameField.Clear();
        UsernameField.SendKeys(username);
        PasswordField.Clear();
        PasswordField.SendKeys(password);
        LoginButton.Click();

        return new HomePage(_driver);
    }

    public string GetErrorText()
    {
        _wait.Until(d =>
        {
            var el = d.FindElement(MobileBy.AccessibilityId("error-message"));
            return !string.IsNullOrEmpty(el.Text);
        });
        return ErrorMessage.Text;
    }

    private void WaitForPageLoaded()
    {
        _wait.Until(d => d.FindElement(MobileBy.AccessibilityId("login-button")));
    }
}

// Usage
[Fact]
public void Login_ValidUser_ReachesHomePage()
{
    var loginPage = new LoginPage(_driver);
    var homePage = loginPage.Login("alice", "P@ssw0rd!");

    Assert.True(homePage.IsLoaded);
}

プラットフォーム固有の動作テスト

プラットフォーム別の条件付きテスト
public class PlatformTests : IClassFixture<AppiumFixture>
{
    private readonly AppiumDriver _driver;

    public PlatformTests(AppiumFixture fixture)
    {
        _driver = fixture.Driver;
    }

    [Fact]
    [Trait("Platform", "Android")]
    public void BackButton_Android_NavigatesBack()
    {
        // xUnit v3 native skip support (no SkippableFact package needed)
        Assert.SkipWhen(TestConfig.TargetPlatform != "Android",
            "Android-only: hardware back button");

        // Navigate to details page
        _driver.FindElement(MobileBy.AccessibilityId("item-1")).Click();

        // Press Android back button
        _driver.Navigate().Back();

        // Verify we returned to the list
        var wait = new WebDriverWait(_driver, TimeSpan.FromSeconds(5));
        wait.Until(d => d.FindElement(MobileBy.AccessibilityId("item-list")));
    }

    [Fact]
    [Trait("Platform", "iOS")]
    public void SwipeToDelete_iOS_RemovesItem()
    {
        // xUnit v3 native skip support
        Assert.SkipWhen(TestConfig.TargetPlatform != "iOS",
            "iOS-only: swipe gesture");

        var item = _driver.FindElement(MobileBy.AccessibilityId("item-1"));

        // Swipe left to reveal delete action
        var swipe = new PointerInputDevice(PointerKind.Touch, "finger");
        var sequence = new ActionSequence(swipe);
        var itemLocation = item.Location;
        var itemSize = item.Size;

        sequence.AddAction(swipe.CreatePointerMove(
            item, itemSize.Width - 10, itemSize.Height / 2,
            TimeSpan.FromMilliseconds(0)));
        sequence.AddAction(swipe.CreatePointerDown(MouseButton.Left));
        sequence.AddAction(swipe.CreatePointerMove(
            item, 10, itemSize.Height / 2,
            TimeSpan.FromMilliseconds(300)));
        sequence.AddAction(swipe.CreatePointerUp(MouseButton.Left));

        _driver.PerformActions([sequence]);

        // Tap the delete button
        var deleteBtn = _driver.FindElement(MobileBy.AccessibilityId("delete-action"));
        deleteBtn.Click();

        // Verify item removed
        var wait = new WebDriverWait(_driver, TimeSpan.FromSeconds(5));
        wait.Until(d =>
        {
            var items = d.FindElements(MobileBy.AccessibilityId("item-1"));
            return items.Count == 0;
        });
    }
}
画面サイズと向き
[Fact]
public void Dashboard_LandscapeMode_ShowsSidePanel()
{
    // Rotate to landscape
    _driver.Orientation = ScreenOrientation.Landscape;

    try
    {
        var wait = new WebDriverWait(_driver, TimeSpan.FromSeconds(5));
        var sidePanel = wait.Until(d =>
            d.FindElement(MobileBy.AccessibilityId("side-panel")));

        Assert.True(sidePanel.Displayed);
    }
    finally
    {
        // Restore portrait
        _driver.Orientation = ScreenOrientation.Portrait;
    }
}

XHarness によるテスト実行

XHarness は、複数のプラットフォームでデバイスやエミュレーター上のテストを実行するためのコマンドラインツールです。アプリのインストール、テストの実行、結果の収集を処理します。

XHarness によるテストの実行
# Install XHarness
dotnet tool install --global Microsoft.DotNet.XHarness.CLI

# Run on Android emulator
xharness android test \
    --app bin/Release/net8.0-android/com.myapp-Signed.apk \
    --package-name com.myapp \
    --instrumentation devicerunner.AndroidInstrumentation \
    --output-directory test-results/android

# Run on iOS simulator
xharness apple test \
    --app bin/Release/net8.0-ios/MyApp.app \
    --target ios-simulator-64 \
    --output-directory test-results/ios

# Run with specific device
xharness android test \
    --app app.apk \
    --package-name com.myapp \
    --device-id emulator-5554 \
    --output-directory test-results
デバイスランナーでの XHarness

デバイス上で直接実行する xUnit テストの場合は、デバイスランナーの NuGet パッケージを追加します。

<PackageReference Include="Microsoft.DotNet.XHarness.TestRunners.Xunit" Version="1.*" />
// In the MAUI test app's MauiProgram.cs
public static MauiApp CreateMauiApp()
{
    var builder = MauiApp.CreateBuilder();
    builder.UseVisualRunner(); // XHarness visual test runner
    return builder.Build();
}

主な原則

  • テスト対象のすべての要素に AutomationId を使用します。 これはクロスプラットフォーム対応の data-testid に相当し、どのプラットフォームでもネイティブのアクセシビリティ識別子にマッピングされます。
  • 単体テストだけでなく、実際のエミュレーター/シミュレーターでテストを実行します。 MAUI の描画、ナビゲーション、プラットフォームサービスの動作は、メモリ内テストとは異なります。
  • 明示的な待機を使用し、暗黙的な待機や遅延は使用しません。 条件を指定した WebDriverWait は信頼できますが、Thread.Sleep や暗黙的な待機はタイミングの問題を隠してしまいます。
  • プラットフォーム固有のテストには [Trait] と Assert.SkipWhen でタグを付けます。 xUnit v3 のネイティブなスキップ機能を使うと、未対応機能による失敗を避けながら、CI でプラットフォームごとに適切なテストを実行できます。
  • 保守性を高めるため、ページオブジェクトモデルを適用します。 MAUI アプリには複雑なナビゲーションフローがあるため、ページオブジェクトを使うと、アプリの成長に合わせてもテストを読みやすく保てます。

エージェントが陥りやすい問題

  1. 待機戦略なしで FindElement を使用しないでください。 ナビゲーション直後は要素がまだ利用できない場合があります。非同期処理やページ遷移の後に表示される要素には、必ず WebDriverWait を使用してください。
  2. エミュレーター/シミュレーターの名前をハードコードしないでください。 CI で利用可能なデバイスを指定できるように、環境変数またはテスト設定を使用してください。CI 環境によって、インストールされているエミュレーターは異なります。
  3. MAUI コントロールに AutomationId を設定し忘れないでください。 設定されていない場合、Appium はプラットフォーム固有のセレクター(XPath、クラス名)にフォールバックします。これらは Android、iOS、Windows で異なるため、クロスプラットフォームテストが失敗します。
  4. macOS 以外のマシンで iOS テストを実行しないでください。 iOS シミュレーターには Xcode が必要で、Xcode は macOS 専用です。プラットフォーム条件に応じてテストをスキップするか、プラットフォームごとに CI パイプラインを分けてください。
  5. Appium サーバーを管理せずに放置しないでください。 Appium は手動ではなく、フィクスチャまたは CI サービスとして起動してください。Appium プロセスを終了し忘れると、ポート競合やテストのハングが発生します。

参考資料

コメント