Unit testing in .NET (#3696)

Mock-based testing in .NET
This commit is contained in:
Mikhail Shilkov 2020-03-11 23:10:01 +01:00 committed by GitHub
parent f01208af3b
commit a95a4d1195
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 414 additions and 33 deletions

1
.gitignore vendored
View file

@ -5,6 +5,7 @@
**/.vscode/
**/.vs/
**/.ionide/
**/.idea/
coverage.cov
*.coverprofile

View file

@ -13,12 +13,15 @@ CHANGELOG
- Ensure Python overlays work as part of our SDK generation
[#4043](https://github.com/pulumi/pulumi/pull/4043)
- Fix terminal gets into a state where UP/DOWN don't work with prompts.
[#4042](https://github.com/pulumi/pulumi/pull/4042)
- Ensure old provider is not used when configuration has changed
[#4051](https://github.com/pulumi/pulumi/pull/4051)
- Support for unit testing and mocking in the .NET SDK.
[#3696](https://github.com/pulumi/pulumi/pull/3696)
## 1.12.0 (2020-03-04)
- Avoid Configuring providers which are not used during preview.

View file

@ -22,6 +22,7 @@ namespace Pulumi.Tests
Deployment.Instance = mock.Object;
await func().ConfigureAwait(false);
Deployment.Instance = null!;
}
protected static Task RunInPreview(Action action)

View file

@ -15,10 +15,10 @@ namespace Pulumi.Tests
private class ValidStack : Stack
{
[Output("foo")]
public Output<string> ExplicitName { get; }
public Output<string> ExplicitName { get; set; }
[Output]
public Output<string> ImplicitName { get; }
public Output<string> ImplicitName { get; set; }
public ValidStack()
{
@ -51,7 +51,7 @@ namespace Pulumi.Tests
}
catch (RunException ex)
{
Assert.Contains("foo", ex.Message);
Assert.Contains("Output(s) 'foo' have no value assigned", ex.ToString());
return;
}
@ -61,7 +61,7 @@ namespace Pulumi.Tests
private class InvalidOutputTypeStack : Stack
{
[Output("foo")]
public string Foo { get; }
public string Foo { get; set; }
public InvalidOutputTypeStack()
{
@ -78,7 +78,7 @@ namespace Pulumi.Tests
}
catch (RunException ex)
{
Assert.Contains("foo", ex.Message);
Assert.Contains("Output(s) 'foo' have incorrect type", ex.ToString());
return;
}

View file

@ -13,14 +13,14 @@ namespace Pulumi
{
private readonly object _logGate = new object();
private readonly IDeploymentInternal _deployment;
private readonly Engine.EngineClient _engine;
private readonly IEngine _engine;
// We serialize all logging tasks so that the engine doesn't hear about them out of order.
// This is necessary for streaming logs to be maintained in the right order.
private Task _lastLogTask = Task.CompletedTask;
private int _errorCount;
public Logger(IDeploymentInternal deployment, Engine.EngineClient engine)
public Logger(IDeploymentInternal deployment, IEngine engine)
{
_deployment = deployment;
_engine = engine;

View file

@ -1,10 +1,9 @@
// Copyright 2016-2019, Pulumi Corporation
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Grpc.Core;
using Microsoft.Win32.SafeHandles;
using Pulumirpc;
using Pulumi.Testing;
namespace Pulumi
{
@ -34,6 +33,7 @@ namespace Pulumi
public sealed partial class Deployment : IDeploymentInternal
{
private static IDeployment? _instance;
private static readonly object _instanceLock = new object();
/// <summary>
/// The current running deployment instance. This is only available from inside the function
@ -42,13 +42,12 @@ namespace Pulumi
public static IDeployment Instance
{
get => _instance ?? throw new InvalidOperationException("Trying to acquire Deployment.Instance before 'Run' was called.");
internal set => _instance = (value ?? throw new ArgumentNullException(nameof(value)));
internal set => _instance = value;
}
internal static IDeploymentInternal InternalInstance
=> (IDeploymentInternal)Instance;
private readonly Options _options;
private readonly string _projectName;
private readonly string _stackName;
private readonly bool _isDryRun;
@ -56,8 +55,8 @@ namespace Pulumi
private readonly ILogger _logger;
private readonly IRunner _runner;
internal Engine.EngineClient Engine { get; }
internal ResourceMonitor.ResourceMonitorClient Monitor { get; }
internal IEngine Engine { get; }
internal IMonitor Monitor { get; }
internal Stack? _stack;
internal Stack Stack
@ -93,27 +92,37 @@ namespace Pulumi
_stackName = stack;
_projectName = project;
_options = new Options(
queryMode: queryModeValue, parallel: parallelValue, pwd: pwd,
monitor: monitor, engine: engine, tracing: tracing);
Serilog.Log.Debug("Creating Deployment Engine.");
this.Engine = new Engine.EngineClient(new Channel(engine, ChannelCredentials.Insecure));
this.Engine = new GrpcEngine(engine);
Serilog.Log.Debug("Created Deployment Engine.");
Serilog.Log.Debug("Creating Deployment Monitor.");
this.Monitor = new ResourceMonitor.ResourceMonitorClient(new Channel(monitor, ChannelCredentials.Insecure));
this.Monitor = new GrpcMonitor(monitor);
Serilog.Log.Debug("Created Deployment Monitor.");
_runner = new Runner(this);
_logger = new Logger(this, this.Engine);
}
/// <summary>
/// This constructor is called from <see cref="TestAsync{TStack}"/> with
/// a mocked monitor and dummy values for project and stack.
/// </summary>
private Deployment(IEngine engine, IMonitor monitor, TestOptions? options)
{
_isDryRun = options?.IsPreview ?? true;
_stackName = options?.StackName ?? "stack";
_projectName = options?.ProjectName ?? "project";
this.Engine = engine;
this.Monitor = monitor;
_runner = new Runner(this);
_logger = new Logger(this, this.Engine);
}
string IDeployment.ProjectName => _projectName;
string IDeployment.StackName => _stackName;
bool IDeployment.IsDryRun => _isDryRun;
Options IDeploymentInternal.Options => _options;
ILogger IDeploymentInternal.Logger => _logger;
IRunner IDeploymentInternal.Runner => _runner;

View file

@ -41,7 +41,7 @@ namespace Pulumi
request.Dependencies.AddRange(prepareResult.AllDirectDependencyURNs);
// Now run the operation, serializing the invocation if necessary.
var response = await this.Monitor.ReadResourceAsync(request);
var response = await this.Monitor.ReadResourceAsync(resource, request);
return (response.Urn, id, response.Properties);
}

View file

@ -28,7 +28,7 @@ namespace Pulumi
PopulateRequest(request, prepareResult);
Log.Debug($"Registering resource monitor start: t={type}, name={name}, custom={custom}");
var result = await this.Monitor.RegisterResourceAsync(request);
var result = await this.Monitor.RegisterResourceAsync(resource, request);
Log.Debug($"Registering resource monitor end: t={type}, name={name}, custom={custom}");
return (result.Urn, result.Id, result.Object);
}

View file

@ -33,7 +33,7 @@ namespace Pulumi
Log.Debug($"RegisterResourceOutputs RPC prepared: urn={urn}" +
(_excessiveDebugOutput ? $", outputs ={JsonFormatter.Default.Format(serialized)}" : ""));
await Monitor.RegisterResourceOutputsAsync(new RegisterResourceOutputsRequest()
await Monitor.RegisterResourceOutputsAsync(new RegisterResourceOutputsRequest
{
Urn = urn,
Outputs = serialized,

View file

@ -3,7 +3,9 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Threading.Tasks;
using Pulumi.Testing;
namespace Pulumi
{
@ -87,20 +89,65 @@ namespace Pulumi
public static Task<int> RunAsync<TStack>() where TStack : Stack, new()
=> CreateRunner().RunAsync<TStack>();
/// <summary>
/// Entry point to test a Pulumi application. Deployment will
/// instantiate a new stack instance based on the type passed as TStack
/// type parameter. This method creates no real resources.
/// Note: Currently, unit tests that call <see cref="TestAsync{TStack}"/>
/// must run serially; parallel execution is not supported.
/// </summary>
/// <param name="mocks">Hooks to mock the engine calls.</param>
/// <param name="options">Optional settings for the test run.</param>
/// <typeparam name="TStack">The type of the stack to test.</typeparam>
/// <returns>Test result containing created resources and errors, if any.</returns>
public static async Task<ImmutableArray<Resource>> TestAsync<TStack>(IMocks mocks, TestOptions? options = null) where TStack : Stack, new()
{
var engine = new MockEngine();
var monitor = new MockMonitor(mocks);
Deployment deployment;
lock (_instanceLock)
{
if (_instance != null)
throw new NotSupportedException($"Mulitple executions of {nameof(TestAsync)} must run serially. Please configure your unit test suite to run tests one-by-one.");
deployment = new Deployment(engine, monitor, options);
Instance = deployment;
}
try
{
await deployment._runner.RunAsync<TStack>();
return engine.Errors.Count switch
{
1 => throw new RunException(engine.Errors.Single()),
int v when v > 1 => throw new AggregateException(engine.Errors.Select(e => new RunException(e))),
_ => monitor.Resources.ToImmutableArray()
};
}
finally
{
lock (_instanceLock)
{
_instance = null;
}
}
}
private static IRunner CreateRunner()
{
// Serilog.Log.Logger = new LoggerConfiguration().MinimumLevel.Debug().WriteTo.Console().CreateLogger();
Serilog.Log.Debug("Deployment.Run called.");
if (_instance != null)
lock (_instanceLock)
{
throw new NotSupportedException("Deployment.Run can only be called a single time.");
}
if (_instance != null)
throw new NotSupportedException("Deployment.Run can only be called a single time.");
Serilog.Log.Debug("Creating new Deployment.");
var deployment = new Deployment();
Instance = deployment;
return deployment._runner;
Serilog.Log.Debug("Creating new Deployment.");
var deployment = new Deployment();
Instance = deployment;
return deployment._runner;
}
}
}
}

View file

@ -0,0 +1,27 @@
// Copyright 2016-2020, Pulumi Corporation
using System.Threading.Tasks;
using Grpc.Core;
using Pulumirpc;
namespace Pulumi
{
internal class GrpcEngine : IEngine
{
private readonly Engine.EngineClient _engine;
public GrpcEngine(string engine)
{
this._engine = new Engine.EngineClient(new Channel(engine, ChannelCredentials.Insecure));
}
public async Task LogAsync(LogRequest request)
=> await this._engine.LogAsync(request);
public async Task<SetRootResourceResponse> SetRootResourceAsync(SetRootResourceRequest request)
=> await this._engine.SetRootResourceAsync(request);
public async Task<GetRootResourceResponse> GetRootResourceAsync(GetRootResourceRequest request)
=> await this._engine.GetRootResourceAsync(request);
}
}

View file

@ -0,0 +1,30 @@
// Copyright 2016-2020, Pulumi Corporation
using System.Threading.Tasks;
using Grpc.Core;
using Pulumirpc;
namespace Pulumi
{
internal class GrpcMonitor : IMonitor
{
private readonly ResourceMonitor.ResourceMonitorClient _client;
public GrpcMonitor(string monitor)
{
this._client = new ResourceMonitor.ResourceMonitorClient(new Channel(monitor, ChannelCredentials.Insecure));
}
public async Task<InvokeResponse> InvokeAsync(InvokeRequest request)
=> await this._client.InvokeAsync(request);
public async Task<ReadResourceResponse> ReadResourceAsync(Resource resource, ReadResourceRequest request)
=> await this._client.ReadResourceAsync(request);
public async Task<RegisterResourceResponse> RegisterResourceAsync(Resource resource, RegisterResourceRequest request)
=> await this._client.RegisterResourceAsync(request);
public async Task RegisterResourceOutputsAsync(RegisterResourceOutputsRequest request)
=> await this._client.RegisterResourceOutputsAsync(request);
}
}

View file

@ -8,7 +8,6 @@ namespace Pulumi
{
internal interface IDeploymentInternal : IDeployment
{
Options Options { get; }
string? GetConfig(string fullKey);
Stack Stack { get; set; }

View file

@ -0,0 +1,16 @@
// Copyright 2016-2020, Pulumi Corporation
using System.Threading.Tasks;
using Pulumirpc;
namespace Pulumi
{
internal interface IEngine
{
Task LogAsync(LogRequest request);
Task<SetRootResourceResponse> SetRootResourceAsync(SetRootResourceRequest request);
Task<GetRootResourceResponse> GetRootResourceAsync(GetRootResourceRequest request);
}
}

View file

@ -0,0 +1,18 @@
// Copyright 2016-2020, Pulumi Corporation
using System.Threading.Tasks;
using Pulumirpc;
namespace Pulumi
{
internal interface IMonitor
{
Task<InvokeResponse> InvokeAsync(InvokeRequest request);
Task<ReadResourceResponse> ReadResourceAsync(Resource resource, ReadResourceRequest request);
Task<RegisterResourceResponse> RegisterResourceAsync(Resource resource, RegisterResourceRequest request);
Task RegisterResourceOutputsAsync(RegisterResourceOutputsRequest request);
}
}

View file

@ -198,6 +198,17 @@ Pulumi.StackReferenceArgs.Name.set -> void
Pulumi.StackReferenceArgs.StackReferenceArgs() -> void
Pulumi.StringAsset
Pulumi.StringAsset.StringAsset(string text) -> void
Pulumi.Testing.IMocks
Pulumi.Testing.IMocks.CallAsync(string token, System.Collections.Immutable.ImmutableDictionary<string, object> args, string provider) -> System.Threading.Tasks.Task<object>
Pulumi.Testing.IMocks.NewResourceAsync(string type, string name, System.Collections.Immutable.ImmutableDictionary<string, object> inputs, string provider, string id) -> System.Threading.Tasks.Task<(string id, object state)>
Pulumi.Testing.TestOptions
Pulumi.Testing.TestOptions.TestOptions() -> void
Pulumi.Testing.TestOptions.ProjectName.get -> string
Pulumi.Testing.TestOptions.ProjectName.set -> void
Pulumi.Testing.TestOptions.StackName.get -> string
Pulumi.Testing.TestOptions.StackName.set -> void
Pulumi.Testing.TestOptions.IsPreview.get -> bool?
Pulumi.Testing.TestOptions.IsPreview.set -> void
Pulumi.Union<T0, T1>
Pulumi.Union<T0, T1>.AsT0.get -> T0
Pulumi.Union<T0, T1>.AsT1.get -> T1
@ -223,6 +234,7 @@ static Pulumi.Deployment.RunAsync(System.Action action) -> System.Threading.Task
static Pulumi.Deployment.RunAsync(System.Func<System.Collections.Generic.IDictionary<string, object>> func) -> System.Threading.Tasks.Task<int>
static Pulumi.Deployment.RunAsync(System.Func<System.Threading.Tasks.Task<System.Collections.Generic.IDictionary<string, object>>> func) -> System.Threading.Tasks.Task<int>
static Pulumi.Deployment.RunAsync<TStack>() -> System.Threading.Tasks.Task<int>
static Pulumi.Deployment.TestAsync<TStack>(Pulumi.Testing.IMocks mocks, Pulumi.Testing.TestOptions options = null) -> System.Threading.Tasks.Task<System.Collections.Immutable.ImmutableArray<Pulumi.Resource>>
static Pulumi.Input<T>.implicit operator Pulumi.Input<T>(Pulumi.Output<T> value) -> Pulumi.Input<T>
static Pulumi.Input<T>.implicit operator Pulumi.Input<T>(T value) -> Pulumi.Input<T>
static Pulumi.Input<T>.implicit operator Pulumi.Output<T>(Pulumi.Input<T> input) -> Pulumi.Output<T>

View file

@ -75,7 +75,7 @@ namespace Pulumi.Serialization
{
var propType = prop.PropertyType;
var propFullName = $"[Output] {resource.GetType().FullName}.{prop.Name}";
if (!propType.IsConstructedGenericType &&
if (!propType.IsConstructedGenericType ||
propType.GetGenericTypeDefinition() != typeof(Output<>))
{
throw new InvalidOperationException($"{propFullName} was not an Output<T>");

View file

@ -0,0 +1,36 @@
// Copyright 2016-2020, Pulumi Corporation
using System.Collections.Immutable;
using System.Threading.Tasks;
namespace Pulumi.Testing
{
/// <summary>
/// Hooks to mock the engine that provide test doubles for offline unit testing of stacks.
/// </summary>
public interface IMocks
{
/// <summary>
/// Invoked when a new resource is created by the program.
/// </summary>
/// <param name="type">Resource type name.</param>
/// <param name="name">Resource name.</param>
/// <param name="inputs">Dictionary of resource input properties.</param>
/// <param name="provider">Provider.</param>
/// <param name="id">Resource identifier.</param>
/// <returns>A tuple of a resource identifier and resource state. State can be either a POCO
/// or a dictionary bag.</returns>
Task<(string id, object state)> NewResourceAsync(string type, string name,
ImmutableDictionary<string, object> inputs, string? provider, string? id);
/// <summary>
/// Invoked when the program needs to call a provider to load data (e.g., to retrieve an existing
/// resource).
/// </summary>
/// <param name="token">Function token.</param>
/// <param name="args">Dictionary of input arguments.</param>
/// <param name="provider">Provider.</param>
/// <returns>Invocation result, can be either a POCO or a dictionary bag.</returns>
Task<object> CallAsync(string token, ImmutableDictionary<string, object> args, string? provider);
}
}

View file

@ -0,0 +1,56 @@
// Copyright 2016-2020, Pulumi Corporation
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Pulumi.Serialization;
using Pulumirpc;
namespace Pulumi.Testing
{
internal class MockEngine : IEngine
{
private string? _rootResourceUrn;
private readonly object _rootResourceUrnLock = new object();
public readonly List<string> Errors = new List<string>();
public Task LogAsync(LogRequest request)
{
if (request.Severity == LogSeverity.Error)
{
lock (this.Errors)
{
this.Errors.Add(request.Message);
}
}
return Task.CompletedTask;
}
public Task<SetRootResourceResponse> SetRootResourceAsync(SetRootResourceRequest request)
{
lock (_rootResourceUrnLock)
{
if (_rootResourceUrn != null && _rootResourceUrn != request.Urn)
throw new InvalidOperationException(
$"An invalid attempt to set the root resource to {request.Urn} while it's already set to {_rootResourceUrn}");
_rootResourceUrn = request.Urn;
}
return Task.FromResult(new SetRootResourceResponse());
}
public Task<GetRootResourceResponse> GetRootResourceAsync(GetRootResourceRequest request)
{
lock (_rootResourceUrnLock)
{
if (_rootResourceUrn == null)
throw new InvalidOperationException("Root resource is not set");
return Task.FromResult(new GetRootResourceResponse {Urn = _rootResourceUrn});
}
}
}
}

View file

@ -0,0 +1,103 @@
// Copyright 2016-2020, Pulumi Corporation
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Threading.Tasks;
using Google.Protobuf.WellKnownTypes;
using Pulumi.Serialization;
using Pulumirpc;
namespace Pulumi.Testing
{
internal class MockMonitor : IMonitor
{
private readonly IMocks _mocks;
private readonly Serializer _serializer = new Serializer();
public readonly List<Resource> Resources = new List<Resource>();
public MockMonitor(IMocks mocks)
{
_mocks = mocks;
}
public async Task<InvokeResponse> InvokeAsync(InvokeRequest request)
{
var result = await _mocks.CallAsync(request.Tok, ToDictionary(request.Args), request.Provider)
.ConfigureAwait(false);
return new InvokeResponse {Return = await SerializeAsync(result).ConfigureAwait(false)};
}
public async Task<ReadResourceResponse> ReadResourceAsync(Resource resource, ReadResourceRequest request)
{
var (id, state) = await _mocks.NewResourceAsync(request.Type, request.Name,
ToDictionary(request.Properties), request.Provider, request.Id).ConfigureAwait(false);
lock (this.Resources)
{
this.Resources.Add(resource);
}
return new ReadResourceResponse
{
Urn = NewUrn(request.Parent, request.Type, request.Name),
Properties = await SerializeAsync(state).ConfigureAwait(false)
};
}
public async Task<RegisterResourceResponse> RegisterResourceAsync(Resource resource, RegisterResourceRequest request)
{
var (id, state) = await _mocks.NewResourceAsync(request.Type, request.Name, ToDictionary(request.Object),
request.Provider, request.ImportId).ConfigureAwait(false);
lock (this.Resources)
{
this.Resources.Add(resource);
}
return new RegisterResourceResponse
{
Id = id ?? request.ImportId,
Urn = NewUrn(request.Parent, request.Type, request.Name),
Object = await SerializeAsync(state).ConfigureAwait(false)
};
}
public Task RegisterResourceOutputsAsync(RegisterResourceOutputsRequest request) => Task.CompletedTask;
private static string NewUrn(string parent, string type, string name)
{
if (!string.IsNullOrEmpty(parent))
{
var qualifiedType = parent.Split("::")[2];
var parentType = qualifiedType.Split("$").First();
type = parentType + "$" + type;
}
return "urn:pulumi:" + string.Join("::", new[] { Deployment.Instance.StackName, Deployment.Instance.ProjectName, type, name });
}
private static ImmutableDictionary<string, object> ToDictionary(Struct s)
{
var builder = ImmutableDictionary.CreateBuilder<string, object>();
foreach (var (key, value) in s.Fields)
{
var data = Deserializer.Deserialize(value);
if (data.IsKnown && data.Value != null)
{
builder.Add(key, data.Value);
}
}
return builder.ToImmutable();
}
private async Task<Struct> SerializeAsync(object o)
{
var dict = (o as IDictionary<string, object>)?.ToImmutableDictionary()
?? await _serializer.SerializeAsync("", o).ConfigureAwait(false) as ImmutableDictionary<string, object>
?? throw new InvalidOperationException($"{o.GetType().FullName} is not a supported argument type");
return Serializer.CreateStruct(dict);
}
}
}

View file

@ -0,0 +1,23 @@
namespace Pulumi.Testing
{
/// <summary>
/// Optional settings for <see cref="Deployment.TestAsync{T}"/>.
/// </summary>
public class TestOptions
{
/// <summary>
/// Project name. Defaults to <b>"project"</b> if not specified.
/// </summary>
public string? ProjectName { get; set; }
/// <summary>
/// Stack name. Defaults to <b>"stack"</b> if not specified.
/// </summary>
public string? StackName { get; set; }
/// <summary>
/// Whether the test runs in Preview mode. Defaults to <b>true</b> if not specified.
/// </summary>
public bool? IsPreview { get; set; }
}
}