.NET MAUIテスト戦略完全ガイド:ユニットテスト・UI自動化・CI/CDパイプライン構築

.NET MAUIアプリのテスト戦略を実践的に解説。xUnitとMoqによるViewModelテスト、AppiumでのUI自動化、GitHub ActionsとAzure DevOpsによるCI/CDパイプライン構築まで、すぐに使えるコード例付きで紹介します。

はじめに:なぜ.NET MAUIアプリにテスト戦略が不可欠なのか

「テストを書く時間がない」——開発現場で何度も聞いてきた言葉です。正直なところ、自分も過去のプロジェクトでそう思ったことがあります。でも、モバイルアプリ開発でテストを後回しにしたツケは、リリース後のクラッシュ報告やユーザー離脱という形で、結局は何倍にもなって返ってきました。

.NET MAUI(Multi-platform App UI)は、Android、iOS、Windows、macOSを単一のコードベースでカバーできるクロスプラットフォームフレームワークです。便利ですよね。ただ、プラットフォームが増えるほどテストの重要性も比例して増します。各プラットフォーム固有のUIの挙動、画面サイズの違い、OSバージョンの差異——これらすべてを手動テストだけでカバーするのは、率直に言って無理があります。

この記事では、.NET MAUIアプリのテスト戦略を包括的に解説します。ユニットテストの基本設計からViewModelのモック化、Appiumを活用したUI自動化テスト、そしてGitHub ActionsやAzure DevOpsでのCI/CDパイプライン構築まで。実践的なコード例を交えながら、一つずつ見ていきましょう。

テストピラミッド:.NET MAUIにおける理想的なテスト構成

効果的なテスト戦略を構築するには、まずテストピラミッドの考え方を押さえておく必要があります。

  • ユニットテスト(基盤):ViewModel、サービス、ビジネスロジックなど個々のコンポーネントを単体で検証します。最も数が多く、実行速度が速いのが特徴
  • 統合テスト(中間層):複数のコンポーネントが正しく連携するかを検証。データベースアクセスやAPI呼び出しを含みます
  • UIテスト(頂点):実際のデバイスやエミュレーターでUIの操作を自動化。実行コストは一番高いですが、ユーザー体験を直接検証できるという強みがあります

.NET MAUIプロジェクトでは、テスト対象のコードをプラットフォームに依存しないレイヤーにきちんと分離することで、ユニットテストの比率を高められます。これがテスト全体のコストを下げるカギです。

プロジェクト構成:テスタブルなアーキテクチャの設計

テストしやすいコードを書くには、プロジェクトの構成段階から意識しておくことが大事です。推奨されるソリューション構成は以下の通りです。

MyMauiApp/
├── MyMauiApp/                    # .NET MAUIアプリ本体
│   ├── Views/                    # XAMLページ
│   ├── MauiProgram.cs
│   └── App.xaml.cs
├── MyMauiApp.Core/               # コアビジネスロジック(.NET Standardライブラリ)
│   ├── ViewModels/
│   ├── Services/
│   ├── Models/
│   └── Interfaces/
├── MyMauiApp.Core.Tests/         # ユニットテスト
│   ├── ViewModels/
│   └── Services/
└── MyMauiApp.UITests/            # UI自動化テスト
    ├── Tests/
    └── Pages/

ここでのポイントは、MyMauiApp.Coreプロジェクトの存在です。

ViewModelやサービスクラスをMAUIプロジェクト本体から分離して、通常の.NETクラスライブラリに配置する。こうすることで、プラットフォーム依存のないテストが可能になります。MAUIプロジェクトを直接テストしようとすると、プラットフォーム固有のランタイムが必要になって、テストの実行がかなり面倒になるんですよね。

依存性注入(DI)によるテスタビリティの確保

.NET MAUIにはMicrosoft.Extensions.DependencyInjectionが最初から組み込まれています。これを活用して、サービスの依存関係をコンストラクタインジェクションで注入する設計にしておくのが、テスタブルなコードへの第一歩です。

// MauiProgram.cs
public static class MauiProgram
{
    public static MauiApp CreateMauiApp()
    {
        var builder = MauiApp.CreateBuilder();
        builder.UseMauiApp<App>();

        // サービスの登録
        builder.Services.AddSingleton<IApiService, ApiService>();
        builder.Services.AddSingleton<ILocalStorageService, LocalStorageService>();
        builder.Services.AddSingleton<IConnectivityService, ConnectivityService>();

        // ViewModelの登録
        builder.Services.AddTransient<ProductListViewModel>();
        builder.Services.AddTransient<ProductDetailViewModel>();

        // ページの登録
        builder.Services.AddTransient<ProductListPage>();
        builder.Services.AddTransient<ProductDetailPage>();

        return builder.Build();
    }
}

インターフェースを通じて依存関係を定義しておけば、テスト時にモックオブジェクトを簡単に差し替えられます。この「差し替えやすさ」こそがDIの真価です。

ユニットテスト:xUnitとMoqによる実践

さて、ここからが本題です。ユニットテストはテスト戦略の基盤であり、最も重要なレイヤーです。xUnit(テストフレームワーク)、Moq(モッキングライブラリ)、FluentAssertions(アサーションライブラリ)を使った実践的な手法を見ていきましょう。

テストプロジェクトのセットアップ

まずはテストプロジェクトに必要なNuGetパッケージを追加します。

dotnet new xunit -n MyMauiApp.Core.Tests
cd MyMauiApp.Core.Tests
dotnet add reference ../MyMauiApp.Core/MyMauiApp.Core.csproj
dotnet add package Moq
dotnet add package FluentAssertions
dotnet add package CommunityToolkit.Mvvm

ViewModelのテスト

CommunityToolkit.Mvvmを使ったViewModelを例に、テストの書き方を見ていきます。まず、テスト対象となるViewModelのコードです。

// ViewModels/ProductListViewModel.cs
public partial class ProductListViewModel : ObservableObject
{
    private readonly IApiService _apiService;
    private readonly IConnectivityService _connectivityService;

    [ObservableProperty]
    private ObservableCollection<Product> _products = new();

    [ObservableProperty]
    private bool _isLoading;

    [ObservableProperty]
    private string _errorMessage = string.Empty;

    public ProductListViewModel(
        IApiService apiService,
        IConnectivityService connectivityService)
    {
        _apiService = apiService;
        _connectivityService = connectivityService;
    }

    [RelayCommand]
    private async Task LoadProductsAsync()
    {
        if (IsLoading) return;

        try
        {
            IsLoading = true;
            ErrorMessage = string.Empty;

            if (!_connectivityService.IsConnected)
            {
                ErrorMessage = "ネットワーク接続がありません";
                return;
            }

            var products = await _apiService.GetProductsAsync();
            Products = new ObservableCollection<Product>(products);
        }
        catch (Exception ex)
        {
            ErrorMessage = $"データの読み込みに失敗しました: {ex.Message}";
        }
        finally
        {
            IsLoading = false;
        }
    }

    [RelayCommand]
    private async Task DeleteProductAsync(Product product)
    {
        if (product == null) return;

        await _apiService.DeleteProductAsync(product.Id);
        Products.Remove(product);
    }
}

このViewModelに対するテストクラスはこんな感じになります。

// Tests/ViewModels/ProductListViewModelTests.cs
public class ProductListViewModelTests
{
    private readonly Mock<IApiService> _mockApiService;
    private readonly Mock<IConnectivityService> _mockConnectivity;
    private readonly ProductListViewModel _viewModel;

    public ProductListViewModelTests()
    {
        _mockApiService = new Mock<IApiService>();
        _mockConnectivity = new Mock<IConnectivityService>();
        _viewModel = new ProductListViewModel(
            _mockApiService.Object,
            _mockConnectivity.Object);
    }

    [Fact]
    public async Task LoadProductsAsync_WhenConnected_ShouldLoadProducts()
    {
        // Arrange
        var expectedProducts = new List<Product>
        {
            new() { Id = 1, Name = "商品A", Price = 1000 },
            new() { Id = 2, Name = "商品B", Price = 2000 }
        };
        _mockConnectivity.Setup(c => c.IsConnected).Returns(true);
        _mockApiService.Setup(s => s.GetProductsAsync())
            .ReturnsAsync(expectedProducts);

        // Act
        await _viewModel.LoadProductsCommand.ExecuteAsync(null);

        // Assert
        _viewModel.Products.Should().HaveCount(2);
        _viewModel.Products.First().Name.Should().Be("商品A");
        _viewModel.IsLoading.Should().BeFalse();
        _viewModel.ErrorMessage.Should().BeEmpty();
    }

    [Fact]
    public async Task LoadProductsAsync_WhenOffline_ShouldShowError()
    {
        // Arrange
        _mockConnectivity.Setup(c => c.IsConnected).Returns(false);

        // Act
        await _viewModel.LoadProductsCommand.ExecuteAsync(null);

        // Assert
        _viewModel.Products.Should().BeEmpty();
        _viewModel.ErrorMessage.Should().Be("ネットワーク接続がありません");
    }

    [Fact]
    public async Task LoadProductsAsync_WhenApiFails_ShouldShowErrorMessage()
    {
        // Arrange
        _mockConnectivity.Setup(c => c.IsConnected).Returns(true);
        _mockApiService.Setup(s => s.GetProductsAsync())
            .ThrowsAsync(new HttpRequestException("サーバーエラー"));

        // Act
        await _viewModel.LoadProductsCommand.ExecuteAsync(null);

        // Assert
        _viewModel.ErrorMessage.Should().Contain("データの読み込みに失敗しました");
        _viewModel.IsLoading.Should().BeFalse();
    }

    [Fact]
    public async Task DeleteProductAsync_ShouldRemoveFromCollection()
    {
        // Arrange
        var product = new Product { Id = 1, Name = "商品A", Price = 1000 };
        _viewModel.Products.Add(product);
        _mockApiService.Setup(s => s.DeleteProductAsync(1))
            .Returns(Task.CompletedTask);

        // Act
        await _viewModel.DeleteProductCommand.ExecuteAsync(product);

        // Assert
        _viewModel.Products.Should().BeEmpty();
        _mockApiService.Verify(s => s.DeleteProductAsync(1), Times.Once);
    }
}

テストコードを書いていて思うのは、Arrange-Act-Assertのパターンが本当に読みやすいということ。半年後に戻ってきても何をテストしているのかすぐにわかります。

サービスレイヤーのテスト

サービスクラスのテストでは、HTTPリクエストをモックするのにHttpMessageHandlerのサブクラスを使います。ちょっとボイラープレートが多いですが、一度作ってしまえば再利用できます。

// Tests/Services/ApiServiceTests.cs
public class ApiServiceTests
{
    private HttpClient CreateMockHttpClient(
        HttpStatusCode statusCode,
        string content)
    {
        var handler = new MockHttpMessageHandler(statusCode, content);
        return new HttpClient(handler)
        {
            BaseAddress = new Uri("https://api.example.com/")
        };
    }

    [Fact]
    public async Task GetProductsAsync_ShouldReturnProducts()
    {
        // Arrange
        var json = JsonSerializer.Serialize(new List<Product>
        {
            new() { Id = 1, Name = "テスト商品", Price = 500 }
        });
        var httpClient = CreateMockHttpClient(HttpStatusCode.OK, json);
        var service = new ApiService(httpClient);

        // Act
        var products = await service.GetProductsAsync();

        // Assert
        products.Should().HaveCount(1);
        products.First().Name.Should().Be("テスト商品");
    }

    [Fact]
    public async Task GetProductsAsync_WhenServerError_ShouldThrow()
    {
        // Arrange
        var httpClient = CreateMockHttpClient(
            HttpStatusCode.InternalServerError, "");
        var service = new ApiService(httpClient);

        // Act & Assert
        await Assert.ThrowsAsync<HttpRequestException>(
            () => service.GetProductsAsync());
    }
}

// テスト用のモックハンドラー
public class MockHttpMessageHandler : HttpMessageHandler
{
    private readonly HttpStatusCode _statusCode;
    private readonly string _content;

    public MockHttpMessageHandler(
        HttpStatusCode statusCode, string content)
    {
        _statusCode = statusCode;
        _content = content;
    }

    protected override Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request,
        CancellationToken cancellationToken)
    {
        var response = new HttpResponseMessage(_statusCode)
        {
            Content = new StringContent(
                _content,
                Encoding.UTF8,
                "application/json")
        };
        return Task.FromResult(response);
    }
}

プロパティ変更通知のテスト

MVVMパターンでは、INotifyPropertyChangedによる通知が正しく発火するかの検証も見落としがちですが、地味に重要です。バインディングの不具合は原因の特定に時間がかかるので、ここでしっかり押さえておきましょう。

[Fact]
public async Task LoadProductsAsync_ShouldNotifyIsLoadingChanged()
{
    // Arrange
    _mockConnectivity.Setup(c => c.IsConnected).Returns(true);
    _mockApiService.Setup(s => s.GetProductsAsync())
        .ReturnsAsync(new List<Product>());

    var propertyChangedEvents = new List<string>();
    _viewModel.PropertyChanged += (s, e) =>
        propertyChangedEvents.Add(e.PropertyName!);

    // Act
    await _viewModel.LoadProductsCommand.ExecuteAsync(null);

    // Assert
    propertyChangedEvents.Should().Contain("IsLoading");
    propertyChangedEvents.Should().Contain("Products");
}

UI自動化テスト:Appiumによるクロスプラットフォームテスト

ユニットテストでビジネスロジックの正しさを担保できたら、次はUI自動化テストでユーザー体験を検証する番です。.NET MAUIでは、Appiumが公式に推奨されているUI自動化フレームワークです。

Appiumとは

Appiumはオープンソースのモバイルアプリ自動化ツールです。Android、iOS、Windows、macOSを単一のAPIで操作できるのが強み。WebDriverプロトコルに基づいているので、Seleniumの経験がある方なら比較的スムーズに使い始められるはずです。

テスト環境のセットアップ

Appiumの導入手順は以下の通りです。Node.jsが入っていればそこまでハードルは高くありません。

# Appiumサーバーのインストール
npm install -g appium

# プラットフォーム固有のドライバーのインストール
appium driver install uiautomator2  # Android用
appium driver install xcuitest      # iOS用
appium driver install windows       # Windows用
appium driver install mac2          # macOS用

# Appiumサーバーの起動
appium

テストプロジェクトには以下のNuGetパッケージを追加します。

dotnet new xunit -n MyMauiApp.UITests
cd MyMauiApp.UITests
dotnet add package Appium.WebDriver
dotnet add package FluentAssertions

AutomationIdの設定

UI自動化テストで要素を特定するには、AutomationIdプロパティの設定が不可欠です。これを忘れると、テストコードから要素を見つけられなくて困ることになります(実際に経験しました)。

<!-- Views/ProductListPage.xaml -->
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             x:Class="MyMauiApp.Views.ProductListPage"
             AutomationId="ProductListPage">

    <VerticalStackLayout Padding="16">
        <SearchBar AutomationId="ProductSearchBar"
                   Placeholder="商品を検索..."
                   Text="{Binding SearchText}" />

        <Button AutomationId="LoadProductsButton"
                Text="商品を読み込む"
                Command="{Binding LoadProductsCommand}" />

        <ActivityIndicator AutomationId="LoadingIndicator"
                           IsRunning="{Binding IsLoading}"
                           IsVisible="{Binding IsLoading}" />

        <Label AutomationId="ErrorLabel"
               Text="{Binding ErrorMessage}"
               IsVisible="{Binding ErrorMessage, Converter={StaticResource StringToBoolConverter}}"
               TextColor="Red" />

        <CollectionView AutomationId="ProductsCollectionView"
                        ItemsSource="{Binding Products}">
            <CollectionView.ItemTemplate>
                <DataTemplate x:DataType="models:Product">
                    <Frame AutomationId="{Binding Name, StringFormat='Product_{0}'}"
                           Margin="0,4" Padding="12">
                        <VerticalStackLayout>
                            <Label AutomationId="{Binding Name, StringFormat='ProductName_{0}'}"
                                   Text="{Binding Name}"
                                   FontSize="18" FontAttributes="Bold" />
                            <Label Text="{Binding Price, StringFormat='{0:C}'}"
                                   FontSize="14" />
                        </VerticalStackLayout>
                    </Frame>
                </DataTemplate>
            </CollectionView.ItemTemplate>
        </CollectionView>
    </VerticalStackLayout>
</ContentPage>

命名規則のポイント:AutomationIdにはわかりやすく一貫性のある命名規則を採用しましょう。「ページ名_要素の役割」や「要素種別_目的」など、チーム内で統一しておくとテストの保守性が格段に上がります。

ベーステストクラスの構築

各プラットフォーム向けの設定をまとめたベースクラスを作っておくと、個々のテストが書きやすくなります。

// UITests/BaseTest.cs
public abstract class BaseTest : IDisposable
{
    protected AppiumDriver Driver { get; private set; }

    protected BaseTest()
    {
        var platform = Environment.GetEnvironmentVariable("TEST_PLATFORM")
            ?? "android";

        Driver = platform switch
        {
            "android" => CreateAndroidDriver(),
            "ios" => CreateiOSDriver(),
            "windows" => CreateWindowsDriver(),
            _ => throw new ArgumentException($"未対応のプラットフォーム: {platform}")
        };
    }

    private AppiumDriver CreateAndroidDriver()
    {
        var options = new AppiumOptions
        {
            PlatformName = "Android",
            AutomationName = "UiAutomator2",
            App = Environment.GetEnvironmentVariable("ANDROID_APK_PATH")
                ?? "/path/to/app.apk"
        };
        options.AddAdditionalAppiumOption("deviceName", "Pixel_7_API_34");
        options.AddAdditionalAppiumOption("noReset", true);

        return new AppiumDriver(
            new Uri("http://127.0.0.1:4723"), options);
    }

    private AppiumDriver CreateiOSDriver()
    {
        var options = new AppiumOptions
        {
            PlatformName = "iOS",
            AutomationName = "XCUITest",
            App = Environment.GetEnvironmentVariable("IOS_APP_PATH")
                ?? "/path/to/app.app"
        };
        options.AddAdditionalAppiumOption("deviceName", "iPhone 15");
        options.AddAdditionalAppiumOption("platformVersion", "17.0");

        return new AppiumDriver(
            new Uri("http://127.0.0.1:4723"), options);
    }

    private AppiumDriver CreateWindowsDriver()
    {
        var options = new AppiumOptions
        {
            PlatformName = "Windows",
            AutomationName = "Windows",
            App = Environment.GetEnvironmentVariable("WINDOWS_APP_ID")
                ?? "com.mycompany.myapp"
        };

        return new AppiumDriver(
            new Uri("http://127.0.0.1:4723"), options);
    }

    protected AppiumElement FindElement(string automationId)
    {
        try
        {
            return Driver.FindElement(
                MobileBy.Id(automationId));
        }
        catch
        {
            return Driver.FindElement(
                MobileBy.AccessibilityId(automationId));
        }
    }

    protected void WaitForElement(
        string automationId, int timeoutSeconds = 10)
    {
        var wait = new WebDriverWait(Driver,
            TimeSpan.FromSeconds(timeoutSeconds));
        wait.Until(d =>
        {
            try { return FindElement(automationId).Displayed; }
            catch { return false; }
        });
    }

    public void Dispose()
    {
        Driver?.Quit();
        Driver?.Dispose();
    }
}

Page Objectパターンの適用

UI自動化テストの保守性を高めるなら、Page Objectパターンは必須と言っていいでしょう。各画面のUI要素と操作を一つのクラスにまとめることで、UIの変更があってもテストコード全体を修正する必要がなくなります。

// UITests/Pages/ProductListPageObject.cs
public class ProductListPageObject
{
    private readonly AppiumDriver _driver;
    private readonly BaseTest _test;

    public ProductListPageObject(AppiumDriver driver, BaseTest test)
    {
        _driver = driver;
        _test = test;
    }

    // 要素の取得
    private AppiumElement LoadButton =>
        _test.FindElement("LoadProductsButton");
    private AppiumElement SearchBar =>
        _test.FindElement("ProductSearchBar");
    private AppiumElement ErrorLabel =>
        _test.FindElement("ErrorLabel");

    // アクション
    public ProductListPageObject TapLoadProducts()
    {
        LoadButton.Click();
        Thread.Sleep(2000); // ネットワーク待機(本来はより堅牢な待機を推奨)
        return this;
    }

    public ProductListPageObject SearchFor(string keyword)
    {
        SearchBar.Clear();
        SearchBar.SendKeys(keyword);
        return this;
    }

    // アサーション
    public ProductListPageObject AssertProductVisible(string productName)
    {
        _test.WaitForElement($"ProductName_{productName}");
        var element = _test.FindElement($"ProductName_{productName}");
        Assert.True(element.Displayed);
        return this;
    }

    public ProductListPageObject AssertErrorDisplayed()
    {
        Assert.True(ErrorLabel.Displayed);
        return this;
    }

    // スクリーンショットの取得
    public ProductListPageObject TakeScreenshot(string name)
    {
        var screenshot = _driver.GetScreenshot();
        screenshot.SaveAsFile($"screenshots/{name}.png");
        return this;
    }
}

UIテストの実装

Page Objectパターンを使ったテストは、驚くほど読みやすくなります。メソッドチェーンで操作の流れが自然に表現できるのがいいですね。

// UITests/Tests/ProductListTests.cs
public class ProductListTests : BaseTest
{
    [Fact]
    public void ProductList_LoadProducts_ShouldDisplayProducts()
    {
        var page = new ProductListPageObject(Driver, this);

        page.TapLoadProducts()
            .AssertProductVisible("商品A")
            .TakeScreenshot("product_list_loaded");
    }

    [Fact]
    public void ProductList_Search_ShouldFilterProducts()
    {
        var page = new ProductListPageObject(Driver, this);

        page.TapLoadProducts()
            .SearchFor("テスト")
            .AssertProductVisible("テスト商品")
            .TakeScreenshot("search_results");
    }
}

デバイスランナーによるオンデバイステスト

Appiumとは別のアプローチとして、.NET MAUIでは「デバイスランナー」を使って実機やエミュレーター上で直接xUnitテストを実行することもできます。プラットフォーム固有のAPIやパーミッション関連のテストには、こちらの方が向いているケースもあります。

// デバイスランナーのセットアップ(MauiProgram.cs内)
public static MauiApp CreateMauiApp()
{
    var builder = MauiApp.CreateBuilder();
    builder.UseMauiApp<App>();

    // テスト実行用の設定
    #if DEBUG
    builder.Services.AddSingleton<IDeviceTestRunner, XunitDeviceRunner>();
    #endif

    return builder.Build();
}

// デバイス固有のテスト例
public class PlatformSpecificTests
{
    [Fact]
    public void Connectivity_ShouldReportCurrentState()
    {
        var connectivity = Connectivity.Current;
        // デバイス上で実行されるため、
        // 実際のネットワーク状態が取得できる
        Assert.NotEqual(
            NetworkAccess.Unknown,
            connectivity.NetworkAccess);
    }

    [Fact]
    public async Task SecureStorage_ShouldStoreAndRetrieveData()
    {
        // SecureStorageはプラットフォーム固有のAPIを使用
        await SecureStorage.SetAsync("test_key", "test_value");
        var result = await SecureStorage.GetAsync("test_key");
        Assert.Equal("test_value", result);

        // クリーンアップ
        SecureStorage.Remove("test_key");
    }
}

CI/CDパイプラインの構築

テストを書いても、毎回手動で実行していたのでは効果は半減です。CI/CDパイプラインに組み込んで、コード変更のたびにテストが自動で走る仕組みを作りましょう。ここが自動化の醍醐味です。

GitHub Actionsによるパイプライン

GitHub Actionsでユニットテストとビルドを自動化するワークフローの例です。yamlファイルをリポジトリに置くだけで動くので、導入のハードルが低いのが魅力です。

# .github/workflows/maui-ci.yml
name: .NET MAUI CI

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

env:
  DOTNET_VERSION: '10.0.x'

jobs:
  unit-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: .NET SDKのセットアップ
        uses: actions/setup-dotnet@v4
        with:
          dotnet-version: ${{ env.DOTNET_VERSION }}

      - name: MAUIワークロードのインストール
        run: dotnet workload install maui

      - name: 依存関係の復元
        run: dotnet restore

      - name: ユニットテストの実行
        run: dotnet test MyMauiApp.Core.Tests/
             --configuration Release
             --logger "trx;LogFileName=test-results.trx"
             --collect:"XPlat Code Coverage"

      - name: テスト結果の公開
        uses: dorny/test-reporter@v1
        if: always()
        with:
          name: 'Unit Test Results'
          path: '**/*.trx'
          reporter: 'dotnet-trx'

      - name: コードカバレッジレポートの生成
        uses: danielpalme/ReportGenerator-GitHub-Action@5
        with:
          reports: '**/coverage.cobertura.xml'
          targetdir: 'coverage-report'

      - name: カバレッジレポートのアップロード
        uses: actions/upload-artifact@v4
        with:
          name: coverage-report
          path: coverage-report

  build-android:
    runs-on: ubuntu-latest
    needs: unit-tests
    steps:
      - uses: actions/checkout@v4

      - name: .NET SDKのセットアップ
        uses: actions/setup-dotnet@v4
        with:
          dotnet-version: ${{ env.DOTNET_VERSION }}

      - name: MAUIワークロードのインストール
        run: dotnet workload install maui-android

      - name: Androidアプリのビルド
        run: dotnet build MyMauiApp/
             -f net10.0-android
             -c Release

  build-ios:
    runs-on: macos-14
    needs: unit-tests
    steps:
      - uses: actions/checkout@v4

      - name: .NET SDKのセットアップ
        uses: actions/setup-dotnet@v4
        with:
          dotnet-version: ${{ env.DOTNET_VERSION }}

      - name: MAUIワークロードのインストール
        run: dotnet workload install maui-ios

      - name: iOSアプリのビルド
        run: dotnet build MyMauiApp/
             -f net10.0-ios
             -c Release
             -p:RuntimeIdentifier=ios-arm64

Azure DevOpsによるパイプライン

Azure DevOpsを使っているチームも多いと思いますので、そちらのYAMLパイプライン構成も紹介しておきます。

# azure-pipelines.yml
trigger:
  branches:
    include:
      - main
      - develop

pool:
  vmImage: 'macos-14'

variables:
  dotnetVersion: '10.0.x'
  buildConfiguration: 'Release'

stages:
  - stage: Test
    displayName: 'テスト実行'
    jobs:
      - job: UnitTests
        displayName: 'ユニットテスト'
        pool:
          vmImage: 'ubuntu-latest'
        steps:
          - task: UseDotNet@2
            inputs:
              packageType: 'sdk'
              version: '$(dotnetVersion)'

          - script: dotnet workload install maui
            displayName: 'MAUIワークロードのインストール'

          - task: DotNetCoreCLI@2
            displayName: 'テストの実行'
            inputs:
              command: 'test'
              projects: '**/*Tests.csproj'
              arguments: >-
                --configuration $(buildConfiguration)
                --collect:"XPlat Code Coverage"

          - task: PublishCodeCoverageResults@2
            displayName: 'カバレッジ結果の公開'
            inputs:
              codeCoverageTool: 'Cobertura'
              summaryFileLocation: '**/coverage.cobertura.xml'

  - stage: Build
    displayName: 'アプリビルド'
    dependsOn: Test
    jobs:
      - job: BuildAndroid
        displayName: 'Android ビルド'
        pool:
          vmImage: 'ubuntu-latest'
        steps:
          - task: UseDotNet@2
            inputs:
              packageType: 'sdk'
              version: '$(dotnetVersion)'

          - script: dotnet workload install maui-android
            displayName: 'Android ワークロードインストール'

          - script: >-
              dotnet publish MyMauiApp/
              -f net10.0-android
              -c $(buildConfiguration)
            displayName: 'Android APKのビルド'

          - task: PublishBuildArtifacts@1
            inputs:
              pathtoPublish: >-
                MyMauiApp/bin/$(buildConfiguration)/
                net10.0-android/publish/
              artifactName: 'android-build'

      - job: BuildiOS
        displayName: 'iOS ビルド'
        pool:
          vmImage: 'macos-14'
        steps:
          - task: UseDotNet@2
            inputs:
              packageType: 'sdk'
              version: '$(dotnetVersion)'

          - script: dotnet workload install maui-ios
            displayName: 'iOS ワークロードインストール'

          - script: >-
              dotnet publish MyMauiApp/
              -f net10.0-ios
              -c $(buildConfiguration)
              -p:RuntimeIdentifier=ios-arm64
              -p:CodesignKey="$(APPLE_CODESIGN_KEY)"
            displayName: 'iOS IPAのビルド'

テストカバレッジとコード品質の管理

テストを書くだけで満足していませんか?カバレッジを継続的にモニタリングすることも、戦略の一部として欠かせません。

カバレッジツールの活用

.NETエコシステムでは、coverletがデファクトスタンダードのカバレッジツールです。CI/CDパイプラインで--collect:"XPlat Code Coverage"オプションを指定するだけで、Cobertura形式のカバレッジレポートが自動生成されます。

# カバレッジしきい値を指定してテストを実行
dotnet test --collect:"XPlat Code Coverage" \
  -- DataCollectionRunSettings.DataCollectors \
  .DataCollector.Configuration.Format=cobertura

# ReportGeneratorでHTMLレポートを生成
dotnet tool install -g dotnet-reportgenerator-globaltool
reportgenerator \
  -reports:"**/coverage.cobertura.xml" \
  -targetdir:"coverage-report" \
  -reporttypes:Html

推奨されるカバレッジの目安

100%のカバレッジを目指す必要はありません。むしろ、100%にこだわりすぎるとテストの保守コストが爆発的に増えます。以下のような現実的な目安を持っておくのがおすすめです。

  • ViewModel:80%以上を目標。すべてのコマンドとプロパティ変更のシナリオをカバー
  • サービスクラス:70%以上を目標。主要なビジネスロジックのパスとエラーハンドリングをカバー
  • コンバーター・バリデーター:90%以上を目標。入力のバリエーションをしっかりテスト
  • UIテスト:主要なユーザーフロー(クリティカルパス)のみで十分。網羅的にやろうとすると逆にメンテが大変になります

テスト戦略のベストプラクティス

最後に、.NET MAUIプロジェクトでテスト戦略を成功させるためのベストプラクティスをまとめておきます。どれも実際のプロジェクトで痛感したことばかりです。

1. テスタブルなコードを設計段階から意識する

テストは後付けではなく、設計の一部です。インターフェースを活用した依存性の注入、単一責任の原則、ViewModelとビューの明確な分離。これらが、テストしやすいコードの基盤になります。

2. AAA(Arrange-Act-Assert)パターンの徹底

すべてのテストメソッドで「準備(Arrange)」「実行(Act)」「検証(Assert)」を明確に分離しましょう。コードレビューでの可読性が大幅に向上しますし、テストの意図が一目でわかります。

3. テストの独立性を保つ

各テストは他のテストに依存せず、どの順序で実行しても同じ結果が得られるようにします。共有状態を使う場合は、テストの前後で確実にリセットすること。これを怠ると、CIで「ローカルでは通るのに...」という地獄を見ることになります。

4. 意味のあるテスト名を付ける

テスト名は「メソッド名_条件_期待される結果」のフォーマットがおすすめです。たとえばLoadProductsAsync_WhenOffline_ShouldShowErrorMessageのように。テストが失敗したとき、名前だけで何が起きたか想像がつきます。

5. フレーキーテスト(不安定なテスト)を放置しない

時々失敗するテスト、ありますよね。あれはチーム全体のテストへの信頼を確実に蝕みます。UIテストでは適切な待機戦略を使い、タイミングに依存するテストは可能な限り排除しましょう。

6. プルリクエストでのテスト実行を必須にする

CI/CDパイプラインで、プルリクエストに対するテスト実行を必須チェックとして設定しておきましょう。テストが通らないコードがmainブランチにマージされるのを防ぐ、最後の砦です。

まとめ

.NET MAUIアプリの品質を支えるテスト戦略は、以下の4つの柱で構成されます。

  1. テスタブルなアーキテクチャ:コアロジックの分離とDIの活用
  2. ユニットテスト:xUnit + Moqによる高速なフィードバックループ
  3. UI自動化テスト:Appiumによるクロスプラットフォームのユーザー体験検証
  4. CI/CDパイプライン:GitHub ActionsやAzure DevOpsによるテストの自動実行

テストは投資です。最初の導入コストはかかりますが、プロジェクトが成長するにつれて、リグレッションの早期発見、リファクタリングへの自信、安定したリリースサイクルという形で確実にリターンが返ってきます。

まずは今日、プロジェクトにユニットテストプロジェクトを一つ追加するところから始めてみてください。完璧を目指す必要はありません。最初の一歩を踏み出すことが、品質の高いアプリ開発への道を切り拓きます。

著者について Editorial Team

Our team of expert writers and editors.