Hvorfor test er uundværligt i mobiludvikling
Lad os starte med elefanten i rummet: alt for mange mobiludviklere springer test over. Ja, det ved vi godt allesammen — og alligevel sker det gang på gang. Testfasen bliver reduceret til et par hurtige manuelle gennemklik lige inden release, og så krydser man fingre. Det er en farlig vane, og ærligt talt har jeg selv været skyldig i det mere end én gang.
Men her er realiteten.
Mobilapps lever i et brutalt miljø. Dine brugere kører på titusinder af forskellige enhedskombinationer — skærmstørrelser, OS-versioner, producentspecifikke tilpasninger, varierende netværksforhold og uforudsigelige livscyklushændelser. En indgående opringning kan afbryde en datasynkronisering. Operativsystemet kan dræbe din app i baggrunden for at frigøre hukommelse. En bruger kan rotere skærmen midt i en formularudfyldning. Det er kaos derude.
Uden en solid teststrategi opdager du først disse problemer, når brugerne gør det — og på det tidspunkt er det for sent. Dårlige anmeldelser i App Store og Google Play er notorisk svære at vende, og en app der crasher har en estimeret frafaldsprocent på over 60% allerede ved andet crash. Det tal burde give enhver mobiludvikler koldsved.
.NET MAUI giver dig en fantastisk cross-platform arkitektur til at bygge apps til Android, iOS, macOS og Windows fra én kodebase. Men den tilgang introducerer også unikke testudfordringer: du skal validere, at din app opfører sig korrekt på alle målplatforme, ikke bare den du tilfældigvis udvikler på. Denne guide giver dig en komplet, lagdelt teststrategi — fra lynhurtige unit tests til fuldt automatiserede UI-tests med Appium — så du kan shippe kvalitetssoftware med tillid.
Testpyramiden tilpasset mobiludvikling
Den klassiske testpyramide gælder også for .NET MAUI, men med nogle mobilspecifikke nuancer. Pyramiden har tre lag, og forholdet mellem dem er helt afgørende for en effektiv teststrategi.
Lag 1: Unit Tests (mange, hurtige, billige)
Unit tests er dit fundament. De tester individuelle enheder af din kode — typisk en metode eller en klasse — i fuldstændig isolation fra resten af systemet. De kører på din udviklingsmaskine uden emulatorer, simulatorer eller fysiske enheder, og de eksekverer på millisekunder. Du bør have hundredvis af dem, og de bør køre på hvert eneste commit. Ingen undtagelser.
Lag 2: Integrationstest og enhedstest (moderate antal)
Integrationstests verificerer, at flere komponenter fungerer korrekt sammen. I .NET MAUI-kontekst kan det betyde test af dine HTTP-klienter mod en test-API, verifikation af din dependency injection-container, eller test af databaseoperationer med SQLite. Nogle af disse tests kræver at køre på en faktisk enhed eller emulator via device runners — det vender vi tilbage til.
Lag 3: UI-tests / End-to-End (få, langsomme, dyre)
UI-tests automatiserer brugerinteraktioner: tryk på knapper, udfyld formularer, naviger mellem sider, og verificer at det visuelle output er korrekt. De er langsomme, potentielt ustabile (det berygtede "flaky"-fænomen) og kræver infrastruktur for at køre. Du bør have færre af dem og reservere dem til de mest kritiske brugerflows.
Den praktiske fordeling for de fleste teams lander omkring 70% unit tests, 20% integrationstests og 10% UI-tests. Det giver den bedste balance mellem testdækning, køretid og vedligeholdelsesomkostninger.
Arkitektur for testbarhed: MVVM er nøglen
Okay, før vi skriver en eneste test, skal vi tale om arkitektur. Model-View-ViewModel (MVVM)-mønsteret er ikke bare en arkitektonisk konvention i .NET MAUI — det er den absolut vigtigste forudsætning for testbar kode. Punktum.
Princippet er enkelt: adskil din forretningslogik fra dit UI. Dine Views (XAML-filer) er tynde lag, der data-binder til ViewModels. ViewModels indeholder al applikationslogik — kommandoer, tilstandshåndtering, validering, navigation — og de er fuldstændig uafhængige af MAUI-runtime. Det betyder, at du kan teste dem i et helt almindeligt .NET-testprojekt uden nogen platformafhængigheder.
Den kritiske designregel er: enhver service din ViewModel afhænger af, skal tilgås via et interface. Navigation, API-kald, lokal lagring, geolokation, konnektivitet — alt skal abstraheres bag interfaces. Det kan virke som ekstra arbejde i starten, men det er præcis det der lader dig udskifte rigtige implementeringer med test-doubles under test.
// Definer interfaces for alle eksterne afhængigheder
public interface IAuthService
{
Task<AuthResult> LoginAsync(string username, string password);
Task LogoutAsync();
bool IsAuthenticated { get; }
}
public interface INavigationService
{
Task NavigateToAsync<TViewModel>() where TViewModel : BaseViewModel;
Task GoBackAsync();
}
// ViewModel afhænger KUN af interfaces
public class LoginViewModel : BaseViewModel
{
private readonly IAuthService _authService;
private readonly INavigationService _navigationService;
public LoginViewModel(IAuthService authService, INavigationService navigationService)
{
_authService = authService;
_navigationService = navigationService;
LoginCommand = new AsyncRelayCommand(ExecuteLoginAsync, CanExecuteLogin);
}
private string _username = string.Empty;
public string Username
{
get => _username;
set => SetProperty(ref _username, value);
}
private string _password = string.Empty;
public string Password
{
get => _password;
set => SetProperty(ref _password, value);
}
private string _errorMessage = string.Empty;
public string ErrorMessage
{
get => _errorMessage;
set => SetProperty(ref _errorMessage, value);
}
public IAsyncRelayCommand LoginCommand { get; }
private bool CanExecuteLogin() =>
!string.IsNullOrWhiteSpace(Username) && !string.IsNullOrWhiteSpace(Password);
private async Task ExecuteLoginAsync()
{
ErrorMessage = string.Empty;
var result = await _authService.LoginAsync(Username, Password);
if (result.IsSuccess)
await _navigationService.NavigateToAsync<MainViewModel>();
else
ErrorMessage = result.ErrorMessage;
}
}
Denne ViewModel har nul afhængigheder af MAUI-frameworket. Den arver ikke fra nogen platformklasse. Den kan instantieres og testes i et hvilket som helst .NET-testprojekt — og det er præcis pointen.
Projektstruktur for testbarhed
Den anbefalede projektstruktur adskiller din testbare kode fra platformspecifik kode. Her er hvad der har fungeret godt for mig:
MinApp.sln
├── src/
│ ├── MinApp/ # .NET MAUI-appprojekt
│ │ ├── Views/
│ │ ├── MauiProgram.cs
│ │ └── MinApp.csproj
│ └── MinApp.Core/ # Klassebibliotek (net9.0 / net10.0)
│ ├── ViewModels/
│ ├── Models/
│ ├── Services/
│ │ └── Interfaces/
│ └── MinApp.Core.csproj
└── tests/
├── MinApp.UnitTests/ # xUnit unit tests
├── MinApp.DeviceTests/ # Device runner tests
└── MinApp.UITests/ # Appium UI-tests
Nøgleinsigten er MinApp.Core-klassebiblioteket. Ved at placere dine ViewModels, services og modeller i et projekt der targeter rent net9.0 eller net10.0, kan dit unit-testprojekt referere direkte til det uden nogen MAUI-komplikationer. Dine tests forbliver hurtige, projektreferencer forbliver simple, og du undgår hovedpinen med at bygge multi-targetede projekter bare for at køre tests.
Unit Testing med xUnit: Det praktiske fundament
xUnit er det anbefalede testframework for .NET MAUI-applikationer. Det bruges i størstedelen af Microsofts egne projekter, og det er standarden i .NET-økosystemet i dag. NUnit og MSTest er også fine valg, men xUnit er der de fleste nye projekter lander.
Opsætning af testprojektet
Opret et xUnit-testprojekt og tilføj de nødvendige NuGet-pakker:
# Opret testprojekt
dotnet new xunit -n MinApp.UnitTests -o tests/MinApp.UnitTests
# Tilføj reference til dit Core-projekt
dotnet add tests/MinApp.UnitTests reference src/MinApp.Core
# Tilføj mocking-bibliotek (NSubstitute anbefales)
dotnet add tests/MinApp.UnitTests package NSubstitute
dotnet add tests/MinApp.UnitTests package FluentAssertions
Hvis din kode der skal testes befinder sig i selve MAUI-appprojektet (og ikke i et separat klassebibliotek), skal du modificere .csproj-filen, så den også targeter net10.0:
<!-- I MinApp.csproj -->
<PropertyGroup>
<TargetFrameworks>net10.0;net10.0-android;net10.0-ios;net10.0-maccatalyst</TargetFrameworks>
<!-- Sørg for at OutputType kun er Exe for platforme -->
<OutputType Condition="'$(TargetFramework)' != 'net10.0'">Exe</OutputType>
</PropertyGroup>
Mocking: Udskift afhængigheder med test-doubles
Mocking er i bund og grund kunsten at erstatte rigtige implementeringer med kontrollerede erstatninger under test. De tre mest populære mocking-biblioteker i .NET er Moq, NSubstitute og FakeItEasy. Jeg hælder personligt til NSubstitute, da syntaksen føles mere naturlig og læsbar — men alle tre gør jobbet fint.
Lad os teste vores LoginViewModel fra tidligere:
using NSubstitute;
using FluentAssertions;
public class LoginViewModelTests
{
private readonly IAuthService _authService;
private readonly INavigationService _navigationService;
private readonly LoginViewModel _sut; // System Under Test
public LoginViewModelTests()
{
_authService = Substitute.For<IAuthService>();
_navigationService = Substitute.For<INavigationService>();
_sut = new LoginViewModel(_authService, _navigationService);
}
[Fact]
public async Task LoginAsync_MedGyldigeBrugernavn_NavigererTilHovedside()
{
// Arrange
_sut.Username = "testbruger";
_sut.Password = "kodeord123";
_authService.LoginAsync("testbruger", "kodeord123")
.Returns(AuthResult.Success());
// Act
await _sut.LoginCommand.ExecuteAsync(null);
// Assert
await _navigationService.Received(1)
.NavigateToAsync<MainViewModel>();
_sut.ErrorMessage.Should().BeEmpty();
}
[Fact]
public async Task LoginAsync_MedUgyldigtKodeord_ViserFejlbesked()
{
// Arrange
_sut.Username = "testbruger";
_sut.Password = "forkert";
_authService.LoginAsync("testbruger", "forkert")
.Returns(AuthResult.Failure("Ugyldigt brugernavn eller kodeord"));
// Act
await _sut.LoginCommand.ExecuteAsync(null);
// Assert
await _navigationService.DidNotReceive()
.NavigateToAsync<MainViewModel>();
_sut.ErrorMessage.Should().Be("Ugyldigt brugernavn eller kodeord");
}
[Theory]
[InlineData("", "kodeord")]
[InlineData("bruger", "")]
[InlineData("", "")]
public void LoginCommand_KanIkkeEksekveres_NaarFelterErTomme(
string username, string password)
{
// Arrange
_sut.Username = username;
_sut.Password = password;
// Assert
_sut.LoginCommand.CanExecute(null).Should().BeFalse();
}
[Fact]
public void LoginCommand_KanEksekveres_NaarBeggeeFelterErUdfyldt()
{
// Arrange
_sut.Username = "bruger";
_sut.Password = "kodeord";
// Assert
_sut.LoginCommand.CanExecute(null).Should().BeTrue();
}
}
Bemærk mønsteret: Arrange-Act-Assert (AAA). Først opsætter vi testdata og mock-adfærd, derefter udfører vi den handling vi vil teste, og til sidst verificerer vi resultatet. Det lyder måske oplagt, men det er utroligt effektivt til at holde tests forudsigelige og nemme at læse.
Test af asynkrone operationer
Mobilapps er fyldt med asynkrone operationer — netværkskald, databaseforespørgsler, filoperationer. Heldigvis håndterer xUnit async/await helt naturligt:
public class ProduktServiceTests
{
[Fact]
public async Task HentProdukter_ReturnererListe_NaarAPIErTilgaengeligt()
{
// Arrange
var httpClient = Substitute.For<IHttpClientService>();
var forventedeProdukter = new List<Produkt>
{
new() { Id = 1, Navn = "Produkt A", Pris = 99.95m },
new() { Id = 2, Navn = "Produkt B", Pris = 149.50m }
};
httpClient.GetAsync<List<Produkt>>("/api/produkter")
.Returns(forventedeProdukter);
var service = new ProduktService(httpClient);
// Act
var resultat = await service.HentProdukterAsync();
// Assert
resultat.Should().HaveCount(2);
resultat.Should().Contain(p => p.Navn == "Produkt A");
}
[Fact]
public async Task HentProdukter_ReturnererTomListe_VedNetvaerksfejl()
{
// Arrange
var httpClient = Substitute.For<IHttpClientService>();
httpClient.GetAsync<List<Produkt>>("/api/produkter")
.ThrowsAsync(new HttpRequestException("Ingen forbindelse"));
var service = new ProduktService(httpClient);
// Act
var resultat = await service.HentProdukterAsync();
// Assert
resultat.Should().BeEmpty();
}
}
Test af PropertyChanged-notifikationer
I MVVM er INotifyPropertyChanged livsnerven i databinding. Det er en af de ting man nemt glemmer at teste, men det kan give virkelig irriterende bugs hvis en property ikke notificerer korrekt:
[Fact]
public void Username_Aendring_RejserPropertyChanged()
{
// Arrange
var rejsteProperties = new List<string>();
_sut.PropertyChanged += (_, e) => rejsteProperties.Add(e.PropertyName!);
// Act
_sut.Username = "nyBruger";
// Assert
rejsteProperties.Should().Contain("Username");
}
Device Testing: Når koden kræver en platform
Visse dele af din kode kan simpelthen ikke testes i et almindeligt .NET-testprojekt. Kode der interagerer med platform-SDK'er — kameraet, GPS, biometrisk autentificering, push-notifikationer — kræver et rigtigt operativsystem at køre på. Og det giver jo god mening, for hvordan vil du teste kameratilgang uden et kamera?
Det er her device runners kommer ind i billedet.
En device runner er i bund og grund en testrunner-app, der kører direkte på en enhed eller emulator. Den giver en visuel grænseflade til at køre tests og kan integreres med XHarness til kommandoliniekørsel og CI/CD-integration.
Opsætning med DeviceRunners
Matt Leibowitz's DeviceRunners-projekt (tilgængeligt på GitHub) leverer device runners til xUnit og NUnit, der er bygget med .NET MAUI:
# Tilføj NuGet-pakken til dit device-testprojekt
dotnet add package Shiny.Xunit.Runners.Maui
Opret derefter et .NET MAUI-appprojekt der fungerer som testhost:
// MauiProgram.cs for dit device-testprojekt
public static class MauiProgram
{
public static MauiApp CreateMauiApp()
{
var builder = MauiApp.CreateBuilder();
builder
.UseMauiApp<App>()
.UseXunitRunner(); // Aktiverer xUnit device runner
return builder.Build();
}
}
Eksempel: Test af platformspecifik kode
public class PlatformTests
{
[Fact]
public void Connectivity_ReturnererAktuelStatus()
{
// Denne test kører på enheden og bruger den rigtige Connectivity API
var status = Connectivity.Current.NetworkAccess;
// Vi tester ikke en specifik værdi, men at API'et virker
status.Should().BeOneOf(
NetworkAccess.Internet,
NetworkAccess.Local,
NetworkAccess.None,
NetworkAccess.ConstrainedInternet);
}
[Fact]
public void DeviceInfo_ReturnererGyldigeVaerdier()
{
var model = DeviceInfo.Current.Model;
var platform = DeviceInfo.Current.Platform;
model.Should().NotBeNullOrEmpty();
platform.Should().NotBeNull();
}
[Fact]
public async Task SecureStorage_KanGemmeOgHenteVaerdi()
{
// Arrange
var noegle = "test_noegle";
var vaerdi = "hemmelig_vaerdi";
// Act
await SecureStorage.Default.SetAsync(noegle, vaerdi);
var hentet = await SecureStorage.Default.GetAsync(noegle);
// Assert
hentet.Should().Be(vaerdi);
// Oprydning
SecureStorage.Default.Remove(noegle);
}
}
Device tests tager naturligvis længere tid at eksekvere end unit tests, fordi de kræver at en emulator eller enhed startes op. Af den grund kører de fleste teams device tests natligt eller på release-branches, ikke på hvert commit. Det er en pragmatisk afvejning.
UI-test med Appium: Automatiser brugeroplevelsen
Appium er det officielt anbefalede UI-testværktøj til .NET MAUI. Det er et open source-framework til mobilautomatisering, der bruger WebDriver-protokollen til at drive native apps. Og det bedste? Det understøtter Android, iOS, macOS og Windows fra et enkelt testprojekt — perfekt til .NET MAUIs cross-platform tilgang.
Sådan fungerer Appium
Appium kører en serverproces, der sender UI-interaktioner til applikationen under test. Den bruger forskellige drivere for hver platform:
- UiAutomator2 — til Android
- XCUITest — til iOS
- Mac2 — til macOS (Mac Catalyst)
- WinAppDriver — til Windows
Installation og opsætning
# Installer Appium globalt via npm
npm install -g appium
# Installer platformspecifikke drivere
appium driver install uiautomator2 # Android
appium driver install xcuitest # iOS
# Verificer installationen
appium driver list --installed
# Start Appium-serveren
appium
Opret derefter et xUnit-testprojekt til UI-tests og tilføj Appium-pakken:
dotnet new xunit -n MinApp.UITests -o tests/MinApp.UITests
dotnet add tests/MinApp.UITests package Appium.WebDriver
AutomationId: Nøglen til pålidelige UI-tests
Det her er virkelig vigtigt, så lyt godt efter: tilføj AutomationId til alle UI-elementer, du vil interagere med i tests. AutomationId er den unikke identifikator, som Appium bruger til at finde elementer på tværs af platforme. Uden den ender du med skrøbelige XPath-selectors, og det er en opskrift på frustration.
<!-- I din XAML -->
<Entry AutomationId="UsernameEntry"
Placeholder="Brugernavn"
Text="{Binding Username}" />
<Entry AutomationId="PasswordEntry"
Placeholder="Kodeord"
IsPassword="True"
Text="{Binding Password}" />
<Button AutomationId="LoginButton"
Text="Log ind"
Command="{Binding LoginCommand}" />
<Label AutomationId="ErrorLabel"
Text="{Binding ErrorMessage}"
TextColor="Red" />
Appium-testopsætning for Android
public class AndroidAppiumSetup : IAsyncLifetime
{
protected AndroidDriver? Driver { get; private set; }
public async Task InitializeAsync()
{
var options = new AppiumOptions
{
PlatformName = "Android",
AutomationName = "UiAutomator2",
App = "/sti/til/com.minapp.maui-Signed.apk",
};
options.AddAdditionalAppiumOption("deviceName", "Pixel_7_API_34");
options.AddAdditionalAppiumOption("noReset", true);
Driver = new AndroidDriver(
new Uri("http://127.0.0.1:4723"), options);
await Task.CompletedTask;
}
public async Task DisposeAsync()
{
Driver?.Quit();
await Task.CompletedTask;
}
protected AppiumElement FindById(string automationId) =>
Driver!.FindElement(MobileBy.Id(automationId));
}
Skrivning af UI-tests
public class LoginSideUITests : AndroidAppiumSetup
{
[Fact]
public async Task Login_MedGyldigeBrugernavn_NavigererTilHovedside()
{
// Arrange - Find UI-elementer via AutomationId
var usernameEntry = FindById("UsernameEntry");
var passwordEntry = FindById("PasswordEntry");
var loginButton = FindById("LoginButton");
// Act - Simuler brugerinteraktion
usernameEntry.SendKeys("testbruger");
passwordEntry.SendKeys("kodeord123");
loginButton.Click();
// Vent på navigation (Appium implicit wait)
await Task.Delay(2000);
// Assert - Verificer at vi er på hovedsiden
var hovedsideElement = FindById("HovedSideTitel");
hovedsideElement.Should().NotBeNull();
hovedsideElement.Text.Should().Contain("Velkommen");
}
[Fact]
public void Login_MedForkertKodeord_ViserFejlbesked()
{
// Arrange
var usernameEntry = FindById("UsernameEntry");
var passwordEntry = FindById("PasswordEntry");
var loginButton = FindById("LoginButton");
// Act
usernameEntry.SendKeys("testbruger");
passwordEntry.SendKeys("forkert_kodeord");
loginButton.Click();
// Assert
var errorLabel = FindById("ErrorLabel");
errorLabel.Text.Should().Contain("Ugyldigt");
}
}
Cross-platform testdeling
En smart tilgang (som jeg varmt kan anbefale) er at adskille testlogikken fra platformkonfigurationen. Opret en delt testklasse med selve testene og platformspecifikke setup-klasser:
// Delt testlogik
public abstract class LoginTestsBase
{
protected abstract AppiumElement FindById(string id);
[Fact]
public void KanIndtasteBrugernavn()
{
var entry = FindById("UsernameEntry");
entry.SendKeys("testbruger");
entry.Text.Should().Be("testbruger");
}
}
// Android-specifik implementation
public class AndroidLoginTests : LoginTestsBase
{
private AndroidDriver _driver;
protected override AppiumElement FindById(string id) =>
_driver.FindElement(MobileBy.Id(id));
}
// iOS-specifik implementation
public class iOSLoginTests : LoginTestsBase
{
private IOSDriver _driver;
protected override AppiumElement FindById(string id) =>
_driver.FindElement(MobileBy.AccessibilityId(id));
}
CI/CD-integration: Automatiser hele testkæden
Tests har kun reel værdi, når de køres konsekvent og automatisk. Lad os være ærlige: manuel testkørsel er for upålidelig. Nogen glemmer det altid — det er bare menneskeligt. CI/CD-pipelines sikrer, at dine tests køres på hvert eneste commit eller pull request, uanset hvad.
GitHub Actions til .NET MAUI-test
Her er et komplet eksempel på en GitHub Actions-workflow, der kører unit tests og bygger appen:
name: .NET MAUI CI
on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Opsæt .NET 10 SDK
uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- name: Gendan pakker
run: dotnet restore
- name: Kør unit tests
run: dotnet test tests/MinApp.UnitTests --configuration Release --logger "trx;LogFileName=testresults.trx"
- name: Publicer testresultater
uses: actions/upload-artifact@v4
if: always()
with:
name: test-results
path: tests/MinApp.UnitTests/TestResults/*.trx
build-android:
needs: test
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- name: Opsæt .NET 10 SDK
uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- name: Installer MAUI workload
run: dotnet workload install maui
- name: Byg Android
run: dotnet publish src/MinApp -f net10.0-android -c Release
build-ios:
needs: test
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- name: Opsæt .NET 10 SDK
uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- name: Installer MAUI workload
run: dotnet workload install maui
- name: Byg iOS
run: dotnet publish src/MinApp -f net10.0-ios -c Release
Azure DevOps Pipeline
For teams der bruger Azure DevOps, er opsætningen ret lig GitHub Actions. Den store fordel her er den dybe integration med Azure-økosystemet og support for Secure Files til certifikathåndtering:
trigger:
branches:
include:
- main
pool:
vmImage: 'windows-latest'
steps:
- task: UseDotNet@2
inputs:
version: '10.0.x'
includePreviewVersions: false
- script: dotnet workload install maui
displayName: 'Installer MAUI workload'
- script: dotnet test tests/MinApp.UnitTests --configuration Release
displayName: 'Kør unit tests'
- script: dotnet publish src/MinApp -f net10.0-android -c Release
displayName: 'Byg Android'
- task: PublishBuildArtifacts@1
inputs:
pathToPublish: 'src/MinApp/bin/Release/net10.0-android/publish'
artifactName: 'android-build'
Teststrategi i pipeline
Den praktiske anbefaling for de fleste teams er:
- På hvert commit/PR: Kør alle unit tests og integrationstests. Disse er hurtige (typisk under 2 minutter) og fanger størstedelen af regressioner.
- Natligt: Kør device tests og et udvalg af UI-tests. De tager længere tid, men giver en dybere validering af platformspecifik funktionalitet.
- Før release: Kør den fulde UI-testsuite på alle målplatforme. Det er den endelige kvalitetskontrol, og her skal man ikke skære hjørner.
Avancerede testteknikker
Snapshot-test af ViewModels
En teknik jeg er blevet ret glad for er snapshot-test. Det er en effektiv måde at fange uventede ændringer i din ViewModel-tilstand. Biblioteket Verify gør det overraskende nemt:
dotnet add package Verify.Xunit
[UsesVerify]
public class ProduktViewModelSnapshotTests
{
[Fact]
public async Task ProduktListe_Snapshot()
{
// Arrange
var service = Substitute.For<IProduktService>();
service.HentAlleAsync().Returns(new List<Produkt>
{
new() { Id = 1, Navn = "Widget", Pris = 29.99m },
new() { Id = 2, Navn = "Gadget", Pris = 59.99m }
});
var vm = new ProduktListeViewModel(service);
await vm.IndlaesDataCommand.ExecuteAsync(null);
// Assert - Sammenlignes med gemt snapshot
await Verify(new
{
vm.Produkter,
vm.ErIndlaest,
vm.HarFejl
});
}
}
Ved første kørsel opretter Verify en .verified.txt-fil med den serialiserede tilstand. Ved efterfølgende kørsler sammenlignes den aktuelle tilstand med den gemte — enhver afvigelse fejler testen. Det er fantastisk til at opdage utilsigtede sideeffekter, der ellers ville snige sig igennem.
Test af navigation
Navigation er en af de mest fejlbehæftede dele af mobilapps (og en af de ting der virkelig irriterer brugere, når det går galt). Test den grundigt via mocked navigation services:
[Fact]
public async Task VaelgProdukt_NavigererTilDetaljer_MedKorrektId()
{
// Arrange
var navigationService = Substitute.For<INavigationService>();
var vm = new ProduktListeViewModel(produktService, navigationService);
var produkt = new Produkt { Id = 42, Navn = "TestProdukt" };
// Act
await vm.VaelgProduktCommand.ExecuteAsync(produkt);
// Assert
await navigationService.Received(1)
.NavigateToAsync<ProduktDetaljerViewModel>(
Arg.Is<Dictionary<string, object>>(p =>
(int)p["ProduktId"] == 42));
}
Test af fejlhåndtering og netværksrobusthed
Mobilapps skal håndtere netværksfejl gracefully — det er ikke en nice-to-have, det er et krav. Skriv eksplicitte tests for fejlscenarier:
[Fact]
public async Task IndlaesData_VedTimeout_ViserRetryBesked()
{
// Arrange
var service = Substitute.For<IDataService>();
service.HentDataAsync()
.ThrowsAsync(new TaskCanceledException("Request timeout"));
var vm = new DataViewModel(service);
// Act
await vm.IndlaesDataCommand.ExecuteAsync(null);
// Assert
vm.HarFejl.Should().BeTrue();
vm.FejlBesked.Should().Contain("Kunne ikke oprette forbindelse");
vm.VisRetryKnap.Should().BeTrue();
}
[Fact]
public async Task IndlaesData_VedForbindelseTabt_BrugerCachedData()
{
// Arrange
var service = Substitute.For<IDataService>();
var cache = Substitute.For<ICacheService>();
service.HentDataAsync()
.ThrowsAsync(new HttpRequestException());
cache.HentCachedDataAsync()
.Returns(new List<DataItem> { new() { Titel = "Cached" } });
var vm = new DataViewModel(service, cache);
// Act
await vm.IndlaesDataCommand.ExecuteAsync(null);
// Assert
vm.Data.Should().HaveCount(1);
vm.VisCachedBanner.Should().BeTrue();
}
Bedste praksis og faldgruber
Gør det rigtige, ikke bare det lette
- Test adfærd, ikke implementering. Dine tests bør verificere hvad koden gør, ikke hvordan den gør det. Undgå at teste private metoder direkte — det kobler dine tests til implementeringsdetaljer, og så knækker de hver gang du refaktorerer.
- Én assertion per test (som hovedregel). Tests med mange assertions er svære at debugge, fordi den første fejl maskerer resten. Hellere flere små, fokuserede tests end en kæmpe test der prøver at dække alt.
- Brug beskrivende testnavne.
LoginAsync_MedUgyldigtKodeord_ViserFejlbeskeder langt bedre endTest1. Testnavnet bør beskrive scenariet og det forventede resultat — din fremtidige kollega vil takke dig. - Undgå
Thread.Sleepi tests. Brug i stedet async/await med timeouts eller Appiums implicit/explicit waits. Faste ventetider gør tests langsomme og ustabile. - Hold tests uafhængige. Ingen test bør afhænge af resultatet af en anden test. Hver test skal kunne køre isoleret og i vilkårlig rækkefølge.
Almindelige faldgruber
- Overtesting af UI. Det er fristende at skrive Appium-tests for alt, men UI-tests er langsomme og skrøbelige. Test forretningslogik i unit tests og reserver UI-tests til de mest kritiske brugerflows: login, betaling, registrering og den slags.
- Manglende AutomationId. Glem aldrig at tilføje
AutomationIdtil elementer, du vil teste. Uden den er Appium nødt til at finde elementer via XPath eller klasse, og det er skrøbeligt og platformspecifikt. Jeg har spildt mere tid på det end jeg har lyst til at indrømme. - At ignorere flaky tests. En test der fejler tilfældigt er ikke "bare lidt ustabil" — den er et signal om enten en reel race condition i din kode eller en dårligt skrevet test. Adresser det med det samme, ellers vænner teamet sig til at ignorere fejlende tests. Og det er en glidebane.
- At mocke for meget. Hvis du mocker alt, tester du reelt bare din mock-opsætning. Find balancen: mock eksterne afhængigheder (netværk, database, filsystem), men lad interne klasser samarbejde reelt i integrationstests.
Konklusion: Byg en testkultur, ikke bare tests
En effektiv teststrategi for .NET MAUI handler ikke kun om værktøjer og frameworks. Det handler om at opbygge en kultur, hvor tests er en naturlig del af udviklingsprocessen — ikke en eftertanke man skynder sig igennem fredag eftermiddag.
Start med det basale: sørg for at din arkitektur er testbar (MVVM med interface-baserede afhængigheder), skriv unit tests for dine ViewModels og services, og kør dem automatisk i din CI/CD-pipeline. Det alene vil fange størstedelen af fejl, før de når produktion.
Udvid gradvist med device tests for platformspecifik kode og Appium-baserede UI-tests for dine mest kritiske brugerflows. Husk testpyramiden: mange hurtige unit tests i bunden, færre langsomme UI-tests i toppen.
Med .NET 10 og det modne MAUI-økosystem har du alle værktøjerne til rådighed. xUnit for unit tests, DeviceRunners og XHarness for enhedstest, Appium for UI-automatisering, og GitHub Actions eller Azure DevOps til at binde det hele sammen.
Investér tiden nu. Ærligt talt, din fremtidige dig — og dine brugere — vil takke dig for det.