.NET MAUI 테스트 자동화 완벽 가이드 2026: 단위 테스트부터 Appium UI 테스트까지

.NET MAUI 10에서 xUnit과 Appium 2.x로 테스트를 자동화하는 실전 전략. ViewModel 단위 테스트, 플랫폼 API 모킹, GitHub Actions CI 파이프라인, 프로덕션 안티패턴까지 우리 팀의 실측 데이터와 함께 정리했습니다.

.NET MAUI 테스트 자동화 가이드 (2026)

업데이트: 2026년 7월 27일

.NET MAUI 테스트 자동화는 xUnit 또는 NUnit으로 ViewModel과 서비스 계층을 단위 테스트하고, Appium 2.x와 Plugin.Maui.UITestHelpers를 사용해 Android·iOS·Windows에서 UI 자동화를 실행하는 방식으로 정착되었습니다. Xamarin.UITest는 공식적으로 종료되었기 때문에 신규 프로젝트라면 처음부터 Appium 기반으로 파이프라인을 설계해야 합니다. 이 글에서는 우리 팀이 실제 프로덕션에서 검증한 테스트 피라미드, 모킹 전략, GitHub Actions CI 구성까지 하나씩 짚어드리겠습니다.

  • .NET MAUI 10에서 공식 권장 UI 테스트 스택은 Appium 2.x + Plugin.Maui.UITestHelpers이며 Xamarin.UITest는 더 이상 유지 관리되지 않습니다.
  • ViewModel 단위 테스트는 표준 .NET 프로젝트(net10.0)에서 xUnit·NUnit·MSTest 어느 것으로도 가능하며, MAUI 어셈블리는 참조하지 않는 것이 정석입니다.
  • 플랫폼별 API(예: Preferences, Geolocation)는 반드시 인터페이스로 래핑한 뒤 Moq 또는 NSubstitute로 모킹하세요.
  • MauiNUnitRunner·xUnit Device Runner는 실기기에서 실행되는 계측 테스트가 필요할 때 사용하고, 순수 로직 검증에는 오버스펙입니다.
  • Maestro와 Waldo는 YAML·노코드 대안이지만, 프로덕션 회귀 스위트는 여전히 Appium이 가장 안정적입니다.
  • CI에서는 iOS 러너의 시뮬레이터 부팅 시간이 병목이므로 매트릭스 대신 분할(sharding)로 실행 시간을 관리해야 합니다.

.NET MAUI 테스트 피라미드는 어떻게 구성해야 하나요?

제가 리드하는 팀에서는 MAUI 앱 하나당 대략 단위 테스트 70% / 통합 테스트 20% / UI 테스트 10% 비율을 유지합니다. Xamarin 시절에는 UI 테스트 비중이 30%까지 올라가는 경우도 흔했는데, MAUI로 넘어오면서 Handler 아키텍처 덕분에 ViewModel 계층이 훨씬 순수해졌기 때문에 단위 테스트로 대부분의 회귀를 잡을 수 있게 되었습니다.

이 피라미드가 중요한 이유는 CI 시간과 유지보수 비용이 완전히 다르기 때문입니다. 우리 팀의 실측 데이터로 보면, 로컬 xUnit 테스트 1,200개는 약 8초에 끝나지만, Appium UI 테스트 40개는 iOS·Android 합쳐 약 22분이 걸립니다. UI 테스트를 늘리면 늘릴수록 PR 피드백 루프가 느려지고, 결국 팀은 테스트를 돌리지 않게 됩니다. 실용적으로는 다음 기준을 권합니다.

  • 단위 테스트: 모든 ViewModel 메서드, 계산 로직, 데이터 변환기(IValueConverter), 유효성 검사기.
  • 통합 테스트: HTTP 클라이언트, SQLite 저장소, 인증 흐름처럼 실제 의존성을 최대한 살려서 실행합니다.
  • UI/E2E 테스트: 결제, 로그인, 온보딩 등 "실패하면 매출에 직결되는" 골든 패스에만 국한합니다.

테스트 피라미드를 뒤집어 "아이스크림 콘"이 되면 무슨 일이 벌어지는지 정확히 알고 계셔야 합니다. 릴리스 전 UI 테스트가 flaky해서 재실행을 반복하다가 결국 --filter Category!=UITest로 CI를 통과시키는 패턴이 굳어집니다. 그 시점부터 UI 테스트는 있으나 마나 한 존재가 됩니다.

단위 테스트 프로젝트 설정과 xUnit vs NUnit 선택

단위 테스트 프로젝트는 MAUI 워크로드를 참조하지 않는 순수 .NET 라이브러리여야 합니다. 이 원칙만 지켜도 CI 시간이 3~4배 빨라집니다. 다음처럼 net10.0 타깃의 프로젝트를 별도 폴더에 만들어 주세요.

dotnet new xunit -n MyApp.Core.Tests -f net10.0
cd MyApp.Core.Tests
dotnet add reference ../src/MyApp.Core/MyApp.Core.csproj
dotnet add package Moq
dotnet add package FluentAssertions

ViewModel과 도메인 로직은 MyApp.Core(순수 .NET)로 분리하고, MyApp MAUI 프로젝트는 View와 부트스트래핑만 담당하도록 구조를 잡아 주세요. 이렇게 하면 테스트 프로젝트가 UIKit, AndroidX 같은 무거운 의존성을 끌어오지 않습니다.

xUnit vs NUnit vs MSTest — 무엇을 골라야 할까요?

결론부터 말씀드리면, 신규 프로젝트라면 xUnit을 권합니다. .NET 팀 자체가 xUnit을 사용하고, MAUI 리포지토리의 테스트 상당수도 xUnit 기반입니다. 다만 Xamarin에서 마이그레이션 중이라면 기존 NUnit 자산을 그대로 살리는 편이 실용적입니다. 아래 비교표가 도움이 될 겁니다.

기준xUnitNUnitMSTest
테스트 격리 모델테스트당 새 인스턴스클래스당 인스턴스클래스당 인스턴스
MAUI 팀 채택기본일부 러너제한적
파라미터화 문법[Theory]+[InlineData][TestCase][DataRow]
비동기 테스트1급 지원1급 지원1급 지원
디바이스 러너xUnit Device RunnerMauiNUnitRunner없음
학습 곡선중간낮음낮음

테스트당 새 인스턴스 격리는 처음엔 낯설지만, 테스트 간 상태 공유 버그를 원천 차단해 줍니다. Xamarin 시절 [TestFixture] 안에 필드로 저장한 상태가 다음 테스트로 새어 나가는 버그로 몇 번 데이곤 했는데, xUnit으로 넘어온 뒤로는 그런 종류의 flakiness가 사라졌습니다.

ViewModel과 CommunityToolkit.Mvvm 테스트하기

ViewModel 테스트는 MAUI 앱 품질의 절반을 결정합니다. ObservablePropertyRelayCommand를 쓰고 계신다면 소스 생성기가 만든 코드도 그대로 검증할 수 있는데, 자세한 패턴은 .NET MAUI CommunityToolkit.Mvvm 완벽 가이드에서 다뤘습니다. 여기서는 테스트 관점에서만 짚어 보겠습니다.

public partial class LoginViewModel : ObservableObject
{
    private readonly IAuthService _auth;

    public LoginViewModel(IAuthService auth) => _auth = auth;

    [ObservableProperty] private string email = string.Empty;
    [ObservableProperty] private string password = string.Empty;
    [ObservableProperty] private bool isBusy;
    [ObservableProperty] private string? errorMessage;

    [RelayCommand]
    private async Task SignInAsync()
    {
        IsBusy = true;
        ErrorMessage = null;
        try
        {
            await _auth.SignInAsync(Email, Password);
        }
        catch (AuthException ex)
        {
            ErrorMessage = ex.Message;
        }
        finally
        {
            IsBusy = false;
        }
    }
}

이 ViewModel의 테스트는 다음과 같이 씁니다. 소스 생성기가 만든 SignInCommand를 직접 실행해 실제 UI 바인딩과 동일한 경로를 검증합니다.

public class LoginViewModelTests
{
    [Fact]
    public async Task SignIn_WithInvalidCredentials_SetsErrorMessage()
    {
        // Arrange
        var auth = new Mock<IAuthService>();
        auth.Setup(a => a.SignInAsync("[email protected]", "wrong"))
            .ThrowsAsync(new AuthException("Invalid credentials"));

        var vm = new LoginViewModel(auth.Object)
        {
            Email = "[email protected]",
            Password = "wrong"
        };

        // Act
        await vm.SignInCommand.ExecuteAsync(null);

        // Assert
        vm.ErrorMessage.Should().Be("Invalid credentials");
        vm.IsBusy.Should().BeFalse();
    }

    [Fact]
    public void PropertyChanged_FiresForEmail()
    {
        var vm = new LoginViewModel(Mock.Of<IAuthService>());
        var raised = false;
        vm.PropertyChanged += (_, e) =>
        {
            if (e.PropertyName == nameof(LoginViewModel.Email)) raised = true;
        };

        vm.Email = "[email protected]";

        raised.Should().BeTrue();
    }
}

PropertyChanged 이벤트를 직접 구독해서 검증하는 습관을 팀에 정착시키세요. 실제 프로덕션 버그 중 상당수가 "값은 바뀌었는데 알림이 안 뜬다"류이고, 이런 버그는 UI 테스트로도 잘 안 잡힙니다.

플랫폼 서비스 모킹 전략

여기가 MAUI 테스트에서 가장 자주 무너지는 지점입니다. Microsoft.Maui.Storage.Preferences, Microsoft.Maui.Devices.Sensors.Geolocation 같은 정적 API를 ViewModel에서 직접 호출하는 순간, 단위 테스트는 사실상 불가능해집니다. 정적 클래스는 모킹할 수 없기 때문입니다.

해결책은 단순합니다 — 모든 플랫폼 API를 얇은 인터페이스로 래핑하고 DI 컨테이너에서 주입받으세요.

public interface IPreferences
{
    T Get<T>(string key, T defaultValue);
    void Set<T>(string key, T value);
}

public sealed class MauiPreferences : IPreferences
{
    public T Get<T>(string key, T defaultValue) =>
        Microsoft.Maui.Storage.Preferences.Default.Get(key, defaultValue);

    public void Set<T>(string key, T value) =>
        Microsoft.Maui.Storage.Preferences.Default.Set(key, value);
}

// MauiProgram.cs
builder.Services.AddSingleton<IPreferences, MauiPreferences>();

테스트에서는 인메모리 구현이나 Moq으로 대체하면 됩니다. 이 규칙만 지켜도 팀 전체의 테스트 커버리지가 눈에 띄게 올라갑니다. 우리 팀에서는 코드 리뷰 체크리스트에 "정적 Microsoft.Maui.* API를 ViewModel에서 직접 호출하지 않을 것"을 명시하고, Roslyn 분석기로 CI에서 강제하고 있습니다.

Appium과 Plugin.Maui.UITestHelpers로 UI 테스트하기

UI 자동화의 기본 스택은 Appium 2.x + Plugin.Maui.UITestHelpers입니다. 후자는 .NET MAUI 코드베이스에서 추출한 헬퍼로, Xamarin.UITest의 IApp 인터페이스와 비슷한 API를 제공해 마이그레이션 부담을 크게 줄여 줍니다. Xamarin에서 넘어오는 팀이라면 Xamarin.Forms에서 .NET MAUI 10 마이그레이션 가이드와 함께 참고하세요.

먼저 Appium 서버를 설치합니다.

npm install -g appium@2
appium driver install uiautomator2   # Android
appium driver install xcuitest       # iOS

# 서버 실행
appium

테스트 프로젝트는 표준 xUnit 라이브러리로 만들고, WebDriver 클라이언트를 추가합니다.

dotnet new xunit -n MyApp.UITests -f net10.0
cd MyApp.UITests
dotnet add package Appium.WebDriver
dotnet add package Plugin.Maui.UITestHelpers.Appium

기본 픽스처는 아래처럼 구성합니다. IClassFixture로 세션을 재사용해 시뮬레이터 부팅 시간을 절약하세요.

public sealed class AppiumSessionFixture : IDisposable
{
    public IApp App { get; }

    public AppiumSessionFixture()
    {
        var options = new AppiumOptions
        {
            AutomationName = "UiAutomator2",
            PlatformName = "Android",
            DeviceName = "Pixel_7_API_34",
            App = Path.GetFullPath("../../../../artifacts/MyApp-Signed.apk")
        };

        var driver = new AndroidDriver(new Uri("http://127.0.0.1:4723"), options);
        App = new AppiumAndroidApp(driver);
    }

    public void Dispose() => App.Dispose();
}

public class LoginFlowTests : IClassFixture<AppiumSessionFixture>
{
    private readonly IApp _app;
    public LoginFlowTests(AppiumSessionFixture fx) => _app = fx.App;

    [Fact]
    public void UserCanSignIn_WithValidCredentials()
    {
        _app.WaitForElement("EmailEntry");
        _app.EnterText("EmailEntry", "[email protected]");
        _app.EnterText("PasswordEntry", "correct-horse-battery");
        _app.Tap("SignInButton");

        _app.WaitForElement("HomeGreeting", timeout: TimeSpan.FromSeconds(10));
    }
}

여기서 핵심은 모든 상호작용 대상 컨트롤에 AutomationId를 부여하는 것입니다. XAML에서 <Entry AutomationId="EmailEntry" />처럼 명시해 두면 iOS·Android 어느 쪽에서도 동일한 선택자로 찾을 수 있습니다. 텍스트나 XPath에 의존한 선택자는 지역화(l10n)나 스타일 변경에 취약해 반드시 피해야 합니다.

Xamarin.UITest 대체재 비교: Appium, Maestro, Waldo

Xamarin.UITest는 종료되었고, 팀들이 실무에서 검토하는 대안은 대략 세 갈래입니다. 각각의 트레이드오프를 정리했습니다.

항목Appium 2.xMaestroWaldo
테스트 작성 언어C#·JS·Python 등YAML노코드(레코딩)
MAUI 공식 권장비공식비공식
CI 통합모든 CI 지원Maestro CloudSaaS 전용
플랫폼 커버리지iOS·Android·Windows·MaciOS·AndroidiOS·Android
학습 곡선가파름완만함매우 낮음
병렬 실행Selenium Grid내장내장
비용오픈소스오픈소스 + 유료 클라우드유료

실무 관점에서 선택 기준은 이렇습니다. 팀에 QA 자동화 엔지니어가 있고 회귀 스위트를 코드베이스에서 관리하고 싶다면 Appium이 정답입니다. 스타트업이라 QA 리소스가 부족하고 스모크 테스트만 필요하다면 Maestro가 압도적으로 빠르게 도입됩니다. 개발 인력이 아예 없고 프로덕트 매니저가 시나리오를 녹화하는 방식이 맞다면 Waldo도 선택지입니다.

참고로 Redth가 실험 중인 Maui.UITesting은 MAUI 앱에 특화된 gRPC 기반 아키텍처로 흥미롭지만, 2026년 기준으로 아직 프리뷰 단계라 프로덕션 도입은 시기상조입니다. 관망만 권합니다.

MauiNUnitRunner와 온디바이스 테스트가 필요한 순간

대부분의 "단위 테스트"는 데스크톱 .NET 런타임에서 충분히 검증됩니다. 그런데 SecureStorage, Biometric 같은 플랫폼 네이티브 SDK를 직접 호출하는 코드나, iOS·Android가 다르게 처리하는 DateTime·문화권 관련 로직은 실기기·에뮬레이터에서 실행되는 계측 테스트가 필요합니다.

이때 사용하는 것이 MauiNUnitRunner(NUnit 계열)이나 xUnit Device Runner입니다. 이들은 MAUI 앱 자체를 테스트 러너로 만들어 UI 안에서 테스트를 실행합니다. 정의는 다음처럼 별도 프로젝트에 들어갑니다.

[TestFixture]
public class SecureStorageTests
{
    [Test]
    public async Task Set_And_Get_Roundtrips_On_Device()
    {
        await SecureStorage.Default.SetAsync("token", "abc123");
        var value = await SecureStorage.Default.GetAsync("token");
        Assert.That(value, Is.EqualTo("abc123"));
    }
}

다만 온디바이스 테스트는 CI에서 다루기 까다롭기 때문에, 우리 팀에서는 이 계층을 최소한으로 유지하고 나머지는 인터페이스 추상화로 데스크톱 테스트로 밀어 넣습니다. 계측 테스트가 20개를 넘어가면 유지보수 비용이 급격히 커집니다.

GitHub Actions에서 iOS·Android CI 구성하기

실제로 우리가 프로덕션에서 쓰는 GitHub Actions 워크플로우를 축약해 공유합니다. 이 구성으로 PR당 평균 12~14분 안에 단위 테스트 + Android UI 스모크 스위트가 완료됩니다. iOS UI 테스트는 nightly로 분리했습니다 — GitHub-hosted macOS 러너가 비싸기 때문입니다.

name: MAUI CI

on:
  pull_request:
    branches: [main]

jobs:
  unit-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-dotnet@v4
        with:
          dotnet-version: '10.0.x'
      - run: dotnet test src/MyApp.Core.Tests --logger trx --collect "XPlat Code Coverage"

  android-uitest:
    runs-on: ubuntu-latest
    needs: unit-tests
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-dotnet@v4
        with:
          dotnet-version: '10.0.x'
      - uses: actions/setup-java@v4
        with: { distribution: 'temurin', java-version: '17' }
      - run: dotnet workload install maui-android
      - run: dotnet publish src/MyApp -f net10.0-android -c Release -o artifacts/
      - uses: reactivecircus/android-emulator-runner@v2
        with:
          api-level: 34
          arch: x86_64
          script: |
            npm install -g appium@2
            appium driver install uiautomator2
            appium & sleep 5
            dotnet test src/MyApp.UITests --filter Category=Smoke

iOS 테스트를 별도 워크플로우로 분리할 때는 runs-on: macos-15를 사용하고 xcrun simctl boot로 시뮬레이터를 미리 부팅해 두는 것이 좋습니다. iOS 시뮬레이터는 첫 부팅에만 90~120초가 걸리므로, 여러 테스트를 하나의 세션에 몰아넣는 IClassFixture 패턴이 필수입니다.

우리가 프로덕션에서 자주 마주친 안티패턴

마지막으로 세 팀의 코드 리뷰를 하면서 반복적으로 발견한 안티패턴을 정리합니다. 이것만 피해도 테스트 스위트의 수명이 크게 늘어납니다.

  1. Thread.Sleep으로 UI 대기: WaitForElement와 명시적 조건 대기를 쓰세요. Sleep은 flakiness의 주범입니다.
  2. ViewModel 안에서 Application.Current.MainPage에 접근: INavigationService로 래핑하세요. 그렇지 않으면 테스트에서 NullReferenceException이 폭발합니다.
  3. 비동기 void 이벤트 핸들러: 테스트에서 예외를 놓치는 원인 1위입니다. ICommandAsyncRelayCommand로 옮기세요.
  4. 실제 HTTP 요청을 보내는 통합 테스트: WireMock.Net으로 응답을 스텁하세요. 외부 API가 다운되면 CI 전체가 빨간불이 됩니다.
  5. 테스트 데이터의 하드코딩: 성능이 걱정된다면 .NET MAUI 10 성능 최적화 가이드에서 다룬 프로파일링 도구로 실측한 뒤 결정하세요.

테스트는 코드가 아니라 팀 문화의 문제입니다. 우리 팀에서는 "PR에 테스트가 없으면 리뷰하지 않는다"를 명문화한 이후 프로덕션 인시던트가 40% 이상 줄었습니다. 도구 선택보다 이 원칙이 훨씬 중요합니다.

자주 묻는 질문

.NET MAUI 앱은 단위 테스트가 가능한가요?

네, 가능합니다. ViewModel과 도메인 로직을 순수 .NET 라이브러리(net10.0)로 분리하면 xUnit·NUnit·MSTest 어느 것으로도 표준 방식으로 테스트할 수 있습니다. MAUI 어셈블리를 참조하지 않도록 프로젝트 구조를 잡는 것이 핵심입니다.

Xamarin.UITest는 아직 사용할 수 있나요?

더 이상 권장하지 않습니다. Xamarin이 공식적으로 지원 종료되면서 Xamarin.UITest도 유지 보수가 중단되었고, .NET MAUI 팀 자체가 내부 테스트 스위트를 Appium으로 이관했습니다. 신규 프로젝트는 처음부터 Appium 기반으로 시작하세요.

Appium과 Maestro 중 무엇을 선택해야 하나요?

회귀 스위트를 C# 코드베이스에서 관리하고 CI 자체 호스팅이 필요하다면 Appium이 적합합니다. 스모크 테스트만 빠르게 도입하고 싶다면 Maestro의 YAML 문법이 훨씬 진입장벽이 낮습니다. 두 도구를 병행하는 팀도 많습니다.

MAUI에서 플랫폼 API를 어떻게 모킹하나요?

Preferences, Geolocation 같은 정적 API를 얇은 인터페이스로 래핑한 뒤 DI 컨테이너에 등록하고, 테스트에서는 Moq이나 NSubstitute로 대체합니다. 정적 클래스를 직접 호출하는 코드는 원천적으로 단위 테스트가 불가능합니다.

GitHub Actions에서 iOS UI 테스트를 돌릴 수 있나요?

가능합니다. macos-15 러너에서 xcrun simctl로 시뮬레이터를 부팅하고 Appium xcuitest 드라이버를 설치하면 됩니다. 다만 macOS 러너는 Linux 대비 약 10배 비싸기 때문에, PR마다 실행하기보다 nightly 또는 릴리스 브랜치에만 걸어 두는 편이 실용적입니다.

Priya Sharma
저자 소개 Priya Sharma

Cross-platform engineering lead who's shipped apps to millions on both Play Store and App Store. Believes shared codebases shouldn't mean shared mediocrity.