Skip to main content
Intune Commander has a comprehensive test suite with both unit and integration tests. This guide covers testing requirements, patterns, and best practices.

Test Requirements

Coverage Requirement

40% line coverage is enforced in CI. Every PR must maintain or improve coverage. Failing builds will be rejected. Check coverage locally:
dotnet test /p:CollectCoverage=true /p:Threshold=40 /p:ThresholdType=line /p:ThresholdStat=total
Coverage report location:
tests/Intune.Commander.Core.Tests/coverage/coverage.cobertura.xml

Test Framework

xUnit (2.5.3) with NSubstitute (5.3.0) for mocking. From tests/Intune.Commander.Core.Tests/Intune.Commander.Core.Tests.csproj:
<PackageReference Include="xunit" Version="2.5.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="coverlet.collector" Version="8.0.0" />
<PackageReference Include="coverlet.msbuild" Version="8.0.0" />

Test Structure

Project Organization

tests/
  Intune.Commander.Core.Tests/
    Auth/
      IntuneGraphClientFactoryTests.cs
    Models/
      TenantProfileTests.cs
      CloudEndpointsTests.cs
    Services/
      ProfileServiceTests.cs
      CacheServiceTests.cs
      ConfigurationProfileServiceTests.cs
      // ... mirrors src/Intune.Commander.Core/Services/
    Integration/
      GraphIntegrationTestBase.cs
      ConfigurationProfileServiceIntegrationTests.cs
      CompliancePolicyServiceIntegrationTests.cs
      // ... integration tests for Graph services
Test files mirror the source structure in src/Intune.Commander.Core/.

Naming Conventions

Test Class:
public class ConfigurationProfileServiceTests
Pattern: {ClassUnderTest}Tests Test Method:
[Fact]
public async Task ListDeviceConfigurationsAsync_ReturnsEmptyList_WhenNoConfigurations()
Pattern: {MethodName}_{Scenario}_{ExpectedBehavior} Test File:
ConfigurationProfileServiceTests.cs
Pattern: {ClassUnderTest}Tests.cs

Unit Tests

Basic Unit Test Structure

public class ProfileServiceTests
{
    [Fact]
    public async Task LoadAsync_ReturnsProfileStore_WhenFileExists()
    {
        // Arrange
        var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
        Directory.CreateDirectory(tempDir);
        
        try
        {
            var profilePath = Path.Combine(tempDir, "profiles.json");
            var store = new ProfileStore
            {
                Profiles = new List<TenantProfile>
                {
                    new TenantProfile
                    {
                        Id = Guid.NewGuid(),
                        Name = "Test Profile",
                        TenantId = Guid.NewGuid().ToString()
                    }
                }
            };
            
            var json = JsonSerializer.Serialize(store);
            await File.WriteAllTextAsync(profilePath, json);
            
            var service = new ProfileService();
            
            // Act
            var result = await service.LoadAsync();
            
            // Assert
            Assert.NotNull(result);
            Assert.Single(result.Profiles);
            Assert.Equal("Test Profile", result.Profiles[0].Name);
        }
        finally
        {
            // Cleanup
            if (Directory.Exists(tempDir))
                Directory.Delete(tempDir, recursive: true);
        }
    }
}
Key elements:
  • // Arrange - Set up test data
  • // Act - Execute the method under test
  • // Assert - Verify expected outcomes
  • try/finally - Clean up resources (temp files, directories)

Testing with NSubstitute

Creating Mocks

var mockCache = Substitute.For<ICacheService>();
var mockProfileService = Substitute.For<ProfileService>();

Return Values

var expectedData = new List<DeviceConfiguration>
{
    new DeviceConfiguration { Id = "1", DisplayName = "Test Config" }
};

mockService.ListDeviceConfigurationsAsync(Arg.Any<CancellationToken>())
    .Returns(Task.FromResult(expectedData));

Argument Capture

string? capturedKey = null;
DeviceConfiguration? capturedValue = null;

mockCache.SetAsync(
    Arg.Do<string>(k => capturedKey = k),
    Arg.Do<DeviceConfiguration>(v => capturedValue = v),
    Arg.Any<TimeSpan?>(),
    Arg.Any<CancellationToken>()
).Returns(Task.CompletedTask);

// Execute method that calls cache
await someService.SaveConfigAsync(config);

// Verify captured arguments
Assert.Equal("expected-key", capturedKey);
Assert.Equal(config, capturedValue);

Call Verification

// Verify method was called once with specific argument
await mockService.Received(1).GetDeviceConfigurationAsync(
    "config-id", 
    Arg.Any<CancellationToken>()
);

// Verify method was never called
mockService.DidNotReceive().DeleteDeviceConfigurationAsync(
    Arg.Any<string>(), 
    Arg.Any<CancellationToken>()
);

// Verify method was called at least once
await mockService.Received().ListDeviceConfigurationsAsync(
    Arg.Any<CancellationToken>()
);

Testing Graph Services

Problem: GraphServiceClient is sealed and cannot be mocked. Solution: Use reflection-based contract tests to verify interface conformance.

Service Contract Tests

public class ConfigurationProfileServiceTests
{
    [Fact]
    public void ConfigurationProfileService_ImplementsInterface()
    {
        var serviceType = typeof(ConfigurationProfileService);
        var interfaceType = typeof(IConfigurationProfileService);
        
        Assert.True(interfaceType.IsAssignableFrom(serviceType));
    }
    
    [Fact]
    public void AllMethods_AcceptCancellationToken()
    {
        var methods = typeof(IConfigurationProfileService).GetMethods();
        
        foreach (var method in methods)
        {
            var parameters = method.GetParameters();
            var lastParam = parameters.LastOrDefault();
            
            Assert.NotNull(lastParam);
            Assert.Equal(typeof(CancellationToken), lastParam.ParameterType);
            Assert.True(lastParam.IsOptional);
            Assert.Equal(default(CancellationToken), lastParam.DefaultValue);
        }
    }
    
    [Fact]
    public void AllAsyncMethods_ReturnTask()
    {
        var methods = typeof(IConfigurationProfileService)
            .GetMethods()
            .Where(m => m.Name.EndsWith("Async"));
        
        foreach (var method in methods)
        {
            Assert.True(
                method.ReturnType == typeof(Task) ||
                method.ReturnType.IsGenericType && 
                method.ReturnType.GetGenericTypeDefinition() == typeof(Task<>)
            );
        }
    }
    
    [Fact]
    public void Constructor_AcceptsGraphServiceClient()
    {
        var constructors = typeof(ConfigurationProfileService).GetConstructors();
        var constructor = constructors.FirstOrDefault();
        
        Assert.NotNull(constructor);
        var parameters = constructor.GetParameters();
        Assert.Single(parameters);
        Assert.Equal(typeof(GraphServiceClient), parameters[0].ParameterType);
    }
}
These tests verify:
  • Service implements its interface
  • All methods accept CancellationToken
  • All async methods return Task or Task<T>
  • Constructor accepts GraphServiceClient
Why this matters: Since we can’t mock GraphServiceClient, we verify the service contract instead. Integration tests will verify actual behavior.

Theory Tests (Parameterized)

[Theory]
[InlineData(CloudEnvironment.Commercial, "https://graph.microsoft.com")]
[InlineData(CloudEnvironment.GCC, "https://graph.microsoft.com")]
[InlineData(CloudEnvironment.GCCHigh, "https://graph.microsoft.us")]
[InlineData(CloudEnvironment.DoD, "https://dod-graph.microsoft.us")]
public void GetEndpoints_ReturnsCorrectGraphUrl(CloudEnvironment cloud, string expectedUrl)
{
    // Act
    var endpoints = CloudEndpoints.GetEndpoints(cloud);
    
    // Assert
    Assert.Equal(expectedUrl, endpoints.GraphBaseUrl);
}
Use [Theory] when:
  • Testing multiple input/output combinations
  • Validating behavior across different scenarios
  • Reducing test duplication

Integration Tests

Integration Test Setup

Integration tests run against a live Azure tenant with a real Graph API connection. Requirements:
  • Test tenant (non-production)
  • App registration with required permissions
  • Environment variables:
    • AZURE_TENANT_ID
    • AZURE_CLIENT_ID
    • AZURE_CLIENT_SECRET
Setup script:
# Run from repository root
.\scripts\Setup-IntegrationTestApp.ps1
This creates an app registration with all required Graph permissions. See docs/GRAPH-PERMISSIONS.md for the complete list.

Base Class: GraphIntegrationTestBase

public abstract class GraphIntegrationTestBase
{
    protected GraphServiceClient GraphClient { get; }
    
    protected GraphIntegrationTestBase()
    {
        var tenantId = Environment.GetEnvironmentVariable("AZURE_TENANT_ID");
        var clientId = Environment.GetEnvironmentVariable("AZURE_CLIENT_ID");
        var clientSecret = Environment.GetEnvironmentVariable("AZURE_CLIENT_SECRET");
        
        if (string.IsNullOrEmpty(tenantId) || 
            string.IsNullOrEmpty(clientId) || 
            string.IsNullOrEmpty(clientSecret))
        {
            GraphClient = null!; // Will be checked in ShouldSkip()
            return;
        }
        
        var credential = new ClientSecretCredential(tenantId, clientId, clientSecret);
        GraphClient = new GraphServiceClient(credential);
    }
    
    protected bool ShouldSkip()
    {
        return GraphClient == null;
    }
    
    protected async Task<T> RetryOnTransientFailureAsync<T>(
        Func<Task<T>> action, 
        int maxRetries = 3)
    {
        var attempt = 0;
        while (true)
        {
            try
            {
                return await action();
            }
            catch (ServiceException ex) when (
                ex.StatusCode == System.Net.HttpStatusCode.TooManyRequests && 
                attempt < maxRetries)
            {
                attempt++;
                await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, attempt)));
            }
        }
    }
}

Writing Integration Tests

Read-Only Tests (Safe)

[Trait("Category", "Integration")]
public class ConfigurationProfileServiceIntegrationTests : GraphIntegrationTestBase
{
    [Fact]
    public async Task ListDeviceConfigurationsAsync_ReturnsData()
    {
        if (ShouldSkip()) return;
        
        var service = new ConfigurationProfileService(GraphClient);
        
        var result = await service.ListDeviceConfigurationsAsync();
        
        Assert.NotNull(result);
        // Note: May be empty list, but should not throw
    }
    
    [Fact]
    public async Task GetDeviceConfigurationAsync_ReturnsNull_WhenNotFound()
    {
        if (ShouldSkip()) return;
        
        var service = new ConfigurationProfileService(GraphClient);
        
        var result = await service.GetDeviceConfigurationAsync(
            "00000000-0000-0000-0000-000000000000");
        
        Assert.Null(result);
    }
}

CRUD Tests (With Cleanup)

[Trait("Category", "Integration")]
public class ConfigurationProfileServiceIntegrationTests : GraphIntegrationTestBase
{
    [Fact]
    public async Task CreateDeviceConfigurationAsync_CreatesConfiguration()
    {
        if (ShouldSkip()) return;
        
        var service = new ConfigurationProfileService(GraphClient);
        var testId = Guid.NewGuid().ToString();
        string? createdId = null;
        
        try
        {
            // Arrange
            var config = new Windows10GeneralConfiguration
            {
                DisplayName = $"IntTest_AutoCleanup_{testId}",
                Description = "Integration test configuration"
            };
            
            // Act
            var result = await service.CreateDeviceConfigurationAsync(config);
            createdId = result.Id;
            
            // Assert
            Assert.NotNull(result);
            Assert.NotNull(result.Id);
            Assert.Equal(config.DisplayName, result.DisplayName);
        }
        finally
        {
            // Cleanup
            if (createdId != null)
            {
                try
                {
                    await service.DeleteDeviceConfigurationAsync(createdId);
                }
                catch
                {
                    // Best effort cleanup
                }
            }
        }
    }
}
CRUD test rules:
  • Prefix created objects with IntTest_AutoCleanup_
  • Always clean up in finally block
  • Use unique test IDs (GUID) to avoid conflicts
  • Best-effort cleanup (catch and ignore cleanup errors)

Integration Test Tagging

ALWAYS tag integration tests:
[Trait("Category", "Integration")]
public class SomeServiceIntegrationTests : GraphIntegrationTestBase
{
    // ...
}
This allows CI to run unit tests and integration tests separately:
# Unit tests only (fast, no credentials needed)
dotnet test --filter "Category!=Integration"

# Integration tests only (slow, requires credentials)
dotnet test --filter "Category=Integration"

Running Tests

All Tests (Unit + Integration)

dotnet test

Unit Tests Only

dotnet test --filter "Category!=Integration"

Integration Tests Only

# Set environment variables first
export AZURE_TENANT_ID="your-tenant-id"
export AZURE_CLIENT_ID="your-client-id"
export AZURE_CLIENT_SECRET="your-client-secret"

dotnet test --filter "Category=Integration"

Specific Test Class

dotnet test --filter "FullyQualifiedName~ProfileServiceTests"

Specific Test Method

dotnet test --filter "FullyQualifiedName~ProfileServiceTests.LoadAsync_ReturnsProfileStore_WhenFileExists"

With Code Coverage

dotnet test /p:CollectCoverage=true /p:CoverletOutputFormat=cobertura /p:CoverletOutput=./coverage/

With Coverage Threshold

dotnet test /p:CollectCoverage=true /p:Threshold=40 /p:ThresholdType=line /p:ThresholdStat=total
This will fail if coverage drops below 40%.

CI/CD Testing

CI — Test & Coverage

File: .github/workflows/ci-test.yml Trigger: All pushes + PRs to main What it does:
  1. Restores dependencies
  2. Builds solution
  3. Runs unit tests (excludes integration tests)
  4. Enforces 40% line coverage
  5. Uploads coverage report as artifact
Command:
dotnet test Intune.Commander.sln \
  --configuration Release \
  --no-build \
  --verbosity normal \
  --filter "Category!=Integration" \
  /p:CollectCoverage=true \
  /p:CoverletOutputFormat=cobertura \
  /p:CoverletOutput=./coverage/ \
  /p:Include="[Intune.Commander.Core]*" \
  /p:Exclude="[Intune.Commander.Core.Tests]*" \
  /p:Threshold=40 \
  /p:ThresholdType=line \
  /p:ThresholdStat=total

CI — Integration Tests

File: .github/workflows/ci-integration.yml Trigger: Push/PR to main + manual dispatch What it does:
  1. Restores dependencies
  2. Builds solution
  3. Runs integration tests against live tenant
  4. Uses repository secrets for credentials
Required secrets:
  • AZURE_TENANT_ID
  • AZURE_CLIENT_ID
  • AZURE_CLIENT_SECRET
Command:
dotnet test --filter "Category=Integration"

Test Coverage Best Practices

What to Test

High Priority:
  • Public APIs and interfaces
  • Business logic and algorithms
  • Error handling and edge cases
  • Data validation and transformation
Medium Priority:
  • Internal helper methods (if complex)
  • Configuration and setup code
  • Serialization/deserialization
Low Priority (Skip):
  • Simple property getters/setters
  • Auto-generated code (MVVM source generators)
  • UI code (ViewModels are tested, but not Views)

Coverage Anti-Patterns

Don’t:
  • Write tests just to hit coverage numbers
  • Test framework code (e.g., testing that ObservableObject works)
  • Test trivial code (e.g., return _value;)
Do:
  • Focus on meaningful behavior
  • Test edge cases and error paths
  • Verify business logic correctness

Example: Good vs Bad Tests

Bad (just hitting coverage):
[Fact]
public void DisplayName_GetSet_Works()
{
    var profile = new TenantProfile();
    profile.DisplayName = "Test";
    Assert.Equal("Test", profile.DisplayName);
}
Good (testing behavior):
[Fact]
public async Task LoadAsync_MigratesLegacyProfile_WhenLegacyFileExists()
{
    // Arrange: Create legacy profile file
    var legacyPath = Path.Combine(legacyDir, "profiles.json");
    await File.WriteAllTextAsync(legacyPath, legacyJson);
    
    var service = new ProfileService();
    
    // Act: Load should trigger migration
    var result = await service.LoadAsync();
    
    // Assert: New path should exist, legacy should be renamed
    Assert.True(File.Exists(newPath));
    Assert.True(File.Exists(legacyPath + ".migrated"));
    Assert.Equal(expectedProfile.Name, result.Profiles[0].Name);
}

Build docs developers (and LLMs) love