Skip to main content

How to test workflow schemes

Why testing is necessary

Automated testing simplifies the development of business logic because the behavior of workflow schemes can be verified simply by running tests. Reliable tests ensure that scheme changes doesn't mess up existing logic. In addition to reducing development time, this lowers the chance of bugs.

What's in the guide

Following this guide, you'll write automated tests for the Vacation request workflow scheme from the Workflow Engine Demo (you can learn more about the Demo in this article). To do this, we'll create a separate project in the sample solution and use the following testing tools:

  • MSTest framework will run and process the tests.
  • Testcontainers package will create an isolated container for the database.
  • Mockups will make it easier to configure tests.

After writing the testing architecture, we'll write three data-driven tests.

Required software

GitHub

You can find the code from this guide in the GitHub repository.

Preparing the environment

As a tested project, we will download a demo from the public access, set up it's launch and prepare the environment for writing a test project. You can learn more about Workflow Engine integration.

Sample setup

Clone our public GitHub repository. Open the solution Samples/ASP.NET Core/MSSQL/WF.Sample.sln using the IDE. You will see the following solution structure:

Solution structure

IDE

To work with a .NET project, you'll need an IDE. In this guide, we're working in JetBrains Rider, the more popular option is Visual Studio.

Database deploying

We'll deploy the database in a docker container. We use the Azure Sql Edge image as the easiest option. Open the console and run the following commands:

docker pull mcr.microsoft.com/azure-sql-edge:latest
docker run --cap-add SYS_PTRACE -e 'ACCEPT_EULA=1' -e 'MSSQL_SA_PASSWORD=StrongPassword#1' -p 1433:1433 --name azuresqledge -d mcr.microsoft.com/azure-sql-edge
  • master default database.
  • sa database login.
  • -e 'MSSQL_SA_PASSWORD=StrongPassword#1' database password.
  • -p 1433:1433 hosting port.
  • --name azuresqledge container name.
Docker

To run these commands, you need to install and run docker.

Now you can connect to the database using this connection string:

Server=localhost,1433;Database=master;User Id=sa;Password=StrongPassword#1;

Next, you should run the init scripts in the database, which we have already downloaded along with the sample in ./DB/* directory. Use the connection string above and your preferred way to run SQL scripts.

The order of scripts execution:

  1. CreatePersistenceObjects.sql.
  2. CreateObjects.sql.
  3. FillData.sql.

Build and run

Open the solution. In the appsettings.json file, change the connection string to a new one.

WF.Sample/appsettings.json
{
"Logging": {
"LogLevel": {
"Default": "Warning"
}
},
"AllowedHosts": "*",
"ConnectionStrings": {
"DefaultConnection": "Server=localhost,1433;Database=master;User Id=sa;Password=StrongPassword#1;"
}
}

Build and run the project using the following commands:

dotnet build WF.Sample.sln
dotnet run --project WF.Sample/WF.Sample.csproj

Make sure everything works. You can find the scheme whose behavior we'll be evaluating in the Designer menu. If everything is ok, proceed to the next step.

Workflow scheme

Testcontainers

First of all, we'll work with the Testcontainer package, which will be used to launch the testing database. We'll write several classes to manage and configure the container.

Project creating

Create a new project according to the "Unit test project" template.

dotnet new mstest --name WF.Sample.Tests
dotnet sln add WF.Sample.Tests

Remove the example classes and divide the project into logical parts by creating directories:

  • Mockups
  • Testcontainer
  • Tests
  • configs

And create a static TestData class that will store information common to all tests.

Project structure

Testcontainer configuration

Install package Testcontainers via NuGet. This library allows you to programmatically run database images in docker containers. This will be useful in order to automatically initialize a clean database each time the tests are run.

dotnet add WF.Sample.Tests package Testcontainers

In the ./Testcontainer/ directory, create an AzureConfiguration class inherited from TestcontainerDatabaseConfiguration. It will save the configuration of the Testcontainer.

./Testcontainer/AzureConfiguration.cs
using DotNet.Testcontainers.Builders;
using DotNet.Testcontainers.Configurations;

namespace WF.Sample.Tests.Testcontainers;

public class AzureConfiguration : TestcontainerDatabaseConfiguration
{
private const string AzureSqlEdgeImage = "mcr.microsoft.com/azure-sql-edge";
private const int AzureSqlEdgePort = 1433;

public AzureConfiguration() : this(AzureSqlEdgeImage) { }

public AzureConfiguration(string image) : base(image, AzureSqlEdgePort)
{
Environments["ACCEPT_EULA"] = "Y";
OutputConsumer = Consume.RedirectStdoutAndStderrToStream(new MemoryStream(), new MemoryStream());
}

public override string Database
{
get => "master";
set => throw new NotImplementedException();
}

public override string Username
{
get => "sa";
set => throw new NotImplementedException();
}

public override string Password
{
get => Environments["SA_PASSWORD"];
set => Environments["SA_PASSWORD"] = value;
}

public override IOutputConsumer OutputConsumer { get; }

public override IWaitForContainerOS WaitStrategy => Wait.ForUnixContainer()
.UntilPortIsAvailable(AzureSqlEdgePort)
.UntilMessageIsLogged(OutputConsumer.Stdout, "SQL Server is now ready for client connections");
}

Create an AzureDatabase class that inherits from TestcontainerDatabase. It will override the way the connection string is created.

./Testcontainer/AzureDatabase.cs
using DotNet.Testcontainers.Configurations;
using DotNet.Testcontainers.Containers;
using Microsoft.Extensions.Logging;

namespace WF.Sample.Tests.Testcontainers;

public class AzureDatabase : TestcontainerDatabase
{
internal AzureDatabase(ITestcontainersConfiguration configuration, ILogger logger)
: base(configuration, logger)
{
}

public override string ConnectionString => $"Server={Hostname},{Port};Database={Database};" +
$"User Id={Username};Password={Password};" +
$"TrustServerCertificate=True";
}

To read options from the configuration file, describe them in the DbOptions class.

./Testcontainer/DbOptions.cs
namespace WF.Sample.Tests.Testcontainers;

public class DbOptions
{
public DbOptions(string name)
{
Name = name;
}

public string Name { get; }
public int? Port { get; set; }
public string? Password { get; set; }
}

Testcontainer runtime

Create the DbRuntime class to run and control the Testcontainer.

./Testcontainer/DbRuntime.cs
namespace WF.Sample.Tests.Testcontainers;

public class DbRuntime
{
public DbRuntime(DbOptions options)
{
_options = options;
}

public string Name => _options.Name;

public async Task StartAsync()
{
throw new NotImplementedException();
}

public async Task StopAsync()
{
throw new NotImplementedException();
}

private readonly DbOptions _options;
private AzureDatabase? _dbTestContainer;
}

To set the container settings, create an extension for the inheritors of the TestcontainerDatabaseConfiguration class.

./Testcontainer/Extensions.cs
using DotNet.Testcontainers.Configurations;

namespace WF.Sample.Tests.Testcontainers;

public static class Extensions
{
public static T WithSettings<T>(this T configuration, DbOptions options)
where T : TestcontainerDatabaseConfiguration
{
if (options.Password is not null) configuration.Password = options.Password;
if (options.Port is not null) configuration.Port = options.Port.Value;
return configuration;
}
}

Implement the StartAsync and StopAsync methods in the DbRuntime class. To work with the database install the Microsoft.Data.SqlClient package via NuGet.

dotnet add WF.Sample.Tests package Microsoft.Data.SqlClient
./Testcontainer/DbRuntime.cs
using DotNet.Testcontainers.Builders;
using Microsoft.Data.SqlClient;

namespace WF.Sample.Tests.Testcontainers;

public class DbRuntime
{
public DbRuntime(DbOptions options)
{
_options = options;
}

public string Name => _options.Name;
public string ConnectionString => TestContainer.ConnectionString;
public AzureDatabase TestContainer => _dbTestContainer
?? throw new Exception($"The instance of '{nameof(DbRuntime)}' not initialized");

public async Task StartAsync()
{
_dbTestContainer = new TestcontainersBuilder<AzureDatabase>()
.WithDatabase(new AzureConfiguration().WithSettings(_options))
.Build();

await _dbTestContainer.StartAsync();

//This method is needed to make sure the database is up and ready to accept our requests.
EnsureDatabaseReady();
}

public async Task StopAsync()
{
await TestContainer.DisposeAsync().AsTask();
}

private void EnsureDatabaseReady()
{
for (var i = 0; i < 300; i++)
{
try
{
using var connection = new SqlConnection(ConnectionString);
connection.Open();
return;
}
catch (Exception)
{
Thread.Sleep(100);
}
}
}

private readonly DbOptions _options;
private AzureDatabase? _dbTestContainer;
}

Now we need to create a class for executing SQL scripts. To use the database, we had to run the init scripts first. Create MssqlScriptExecutor class.

./Testcontainer/MssqlScriptExecutor.cs
using System.Data;
using Microsoft.Data.SqlClient;

namespace WF.Sample.Tests.Testcontainers;

public class MssqlScriptExecutor
{
public MssqlScriptExecutor(string connectionString)
{
ConnectionString = connectionString;
}

public string ConnectionString { get; }

public void Execute(string script)
{
if (script.Trim() == String.Empty) return;

var connection = CreateConnection();

if (connection.State != ConnectionState.Open) connection.Open();

using var command = connection.CreateCommand();
command.CommandText = script;
command.ExecuteNonQuery();
}

public async Task ExecuteFileAsync(string? path)
{
if (!File.Exists(path)) return;
var script = await File.ReadAllTextAsync(path);
Execute(script);
}

protected IDbConnection CreateConnection()
{
return new SqlConnection(ConnectionString);
}
}

Aggregate this class in DbRuntime and implement passing the path to the scripts through the constructor. They will be executed after the container is initialized.

./Testcontainer/DbRuntime.cs
using DotNet.Testcontainers.Builders;
using Microsoft.Data.SqlClient;

namespace WF.Sample.Tests.Testcontainers;

public class DbRuntime
{
public DbRuntime(DbOptions options, string[]? scripts = null)
{
_options = options;
_scripts = scripts ?? new string[]{};
}

public string Name => _options.Name;
public string ConnectionString => TestContainer.ConnectionString;
public AzureDatabase TestContainer => _dbTestContainer
?? throw new Exception($"The instance of '{nameof(DbRuntime)}' not initialized");

public MssqlScriptExecutor ScriptExecutor => new(ConnectionString);

public async Task StartAsync()
{
_dbTestContainer = new TestcontainersBuilder<AzureDatabase>()
.WithDatabase(new AzureConfiguration().WithSettings(_options))
.Build();

await _dbTestContainer.StartAsync();

//This method is needed to make sure the database is up and ready to accept our requests.
EnsureDatabaseReady();

foreach (var script in _scripts)
{
await ScriptExecutor.ExecuteFileAsync(script);
}
}

public async Task StopAsync()
{
await TestContainer.DisposeAsync().AsTask();
}

private void EnsureDatabaseReady()
{
for (var i = 0; i < 300; i++)
{
try
{
using var connection = new SqlConnection(ConnectionString);
connection.Open();
return;
}
catch (Exception)
{
Thread.Sleep(100);
}
}
}

private readonly string[] _scripts;
private readonly DbOptions _options;
private AzureDatabase? _dbTestContainer;
}

TestData implementation

Now we should prepare the configuration files and make a DbRuntime factory. The process of creating and controlling DbRuntime will be managed by the TestData class. This class will act as a hub for all testing singletons.

Configure the project .csproj file so that the contents of the ./configs and ../DB folders are always copied to the build directory. To do this, add the following settings:

./WF.Sample.Tests.csproj
<ItemGroup>
<None Include="configs\**" CopyToOutputDirectory="Always" LinkBase="configs\"/>
<None Include="..\DB\**" CopyToOutputDirectory="Always" LinkBase="scripts\"/>
</ItemGroup>

Add the options.json file in configs folder.

./configs/options.json
{
"name": "Mssql",
"port": 11433,
"password": "P@ssw0rd"
}

Now we'll code reading DbOptions in the TestData static constructor. In that class, we'll store constants for accessing configuration files. In the StartAsync and StopAsync methods, all objects used in tests will be created and disposed. Right now it's just DbRuntime, but we'll add a few more services later.

./TestData.cs
using Newtonsoft.Json;
using WF.Sample.Tests.Testcontainers;

namespace WF.Sample.Tests;

public static class TestData
{
static TestData()
{
var optionsJson = File.ReadAllText(Path.Combine(ConfigPath, OptionsConfigName));
var options = JsonConvert.DeserializeObject<DbOptions>(optionsJson)
?? throw new InvalidOperationException("Json deserialization error.");

var scripts = ScriptNames.Select(s => Path.Combine(ScriptPath, s));

_dbRuntime = new DbRuntime(options, scripts.ToArray());
}

public static async Task StartAsync()
{
await _dbRuntime.StartAsync();
}

public static async Task StopAsync()
{
await _dbRuntime.StopAsync();
}

public static string ConnectionString => _dbRuntime.ConnectionString;

public const string ConfigPath = "./configs";
public const string OptionsConfigName = "options.json";

public const string ScriptPath = "./scripts";
public static readonly string[] ScriptNames =
{
"CreatePersistenceObjects.sql",
"CreateObjects.sql",
"FillData.sql"
};

private static readonly DbRuntime _dbRuntime;
}

Mockups & WorkflowRuntime

To limit the set of tested elements and simplify the configuration of the tested project, we use mockups. When creating a WorkflowRuntime object, we'll replace some services with simplified classes which we can control on tests running.

We'll also need access to the main project, so set up the .csproj file to make the references:

dotnet add WF.Sample.Tests reference WF.Sample
dotnet add WF.Sample.Tests reference WF.Sample.Business

DocumentRepository mockup

This repository manages Document DTOs. We will make a simplified version of this class, that just includes the data required for tests. In ./Mockups/ folder Create class TestDocument. The TestDocument(Document document) constructor and the ToDocument method will map this DTO to the original one. The TestDocument(Employee author) constructor will allow us to create new documents in the tests.

./Mockups/TestDocument.cs
using WF.Sample.Business.Model;

namespace WF.Sample.Tests.Mockups;

public class TestDocument
{
public TestDocument(Employee author)
{
Author = author;
Manager = author;
Id = Guid.NewGuid();
Sum = 100;
}

public TestDocument(Document document)
{
Id = document.Id;
Sum = document.Sum;
Author = document.Author;
Manager = document.Manager;
}

public Guid Id { get; set; }
public decimal Sum { get; set; }
public Employee Author { get; set;}
public Employee Manager { get; set; }

public Document ToDocument()
{
return new Document
{
Id = Id,
Name = Id.ToString(),
Comment = String.Empty,
AuthorId = Author.Id,
ManagerId = Manager?.Id,
Sum = Sum,
State = String.Empty,
StateName = String.Empty,
Author = Author,
Manager = Manager,
};
}
}

Now we'll create a mockup of the document repository service. Create the TestDocumentRepository class implement the IDocumentRepository interface. It has a "TestDocument" list and update/returns its elements transferred to the "Document" by requests as if they were stored in the database.

./Mockups/TestDocumentRepository.cs
using OptimaJet.Workflow.Core.Persistence;
using WF.Sample.Business.DataAccess;
using WF.Sample.Business.Model;

namespace WF.Sample.Tests.Mockups;

public class TestDocumentRepository : IDocumentRepository
{
public TestDocumentRepository()
{
Documents = new List<TestDocument>();
}

public List<TestDocument> Documents { get; }

public Document InsertOrUpdate(Document document)
{
var testDocument = Documents.FirstOrDefault(d => d.Id == document.Id);

if (testDocument != null)
{
testDocument.Author = document.Author;
testDocument.Manager = document.Manager;
testDocument.Sum = document.Sum;
}
else
{
Documents.Add(new TestDocument(document));
}

return Documents.First(d => d.Id == document.Id).ToDocument();
}

public List<Document> Get(out int count, int page = 1, int pageSize = 128)
{
var paging = new Paging(page, pageSize);

var result = Documents.Take(paging.PageSize).Skip(paging.SkipCount()).Select(d => d.ToDocument()).ToList();

count = result.Count;

return result;
}

public Document? Get(Guid id, bool loadChildEntities = true)
{
return Documents.FirstOrDefault(d => d.Id == id)?.ToDocument();
}

//We dont track number in TestDocument so we didn't need to implement this method
public Document? GetByNumber(int number)
{
return null;
}

public List<Document> GetByIds(List<Guid> ids)
{
return Documents.Where(d => ids.Contains(d.Id)).Select(d => d.ToDocument()).ToList();
}

public void Delete(Guid[] ids)
{
foreach (var document in Documents)
{
if (ids.Contains(document.Id))
{
Documents.Remove(document);
}
}
}

//We dont track state in TestDocument so we didn't need to implement this method
public void ChangeState(Guid id, string nextState, string nextStateName) { }

//This method is unused so we didn't need to implement it
public bool IsAuthorsBoss(Guid documentId, Guid identityId)
{
throw new NotImplementedException();
}

//This method is unused so we didn't need to implement it
public IEnumerable<string> GetAuthorsBoss(Guid documentId)
{
throw new NotImplementedException();
}
}

EmployeeRepository mockup

In the test configuration, we will make a list of the employees along with their roles. Create the TestEmployeeRepository class implement the IEmployeeRepository interface. It will get a list of employees when it's created and return its elements on requests like in the TestDocumentRepository.

./Mockups/TestEmployeeRepository.cs
using OptimaJet.Workflow.Core.Persistence;
using WF.Sample.Business.DataAccess;
using WF.Sample.Business.Model;

namespace WF.Sample.Tests.Mockups;

public class TestEmployeeRepository : IEmployeeRepository
{
public TestEmployeeRepository(List<Employee> employees)
{
Employees = employees;
}

public const string Unknown = "Unknown";
public List<Employee> Employees { get; }

public List<Employee> GetAll()
{
return new List<Employee>(Employees);
}

public string GetNameById(Guid id)
{
return Employees.FirstOrDefault(e => e.Id == id)?.Name ?? Unknown;
}

public IEnumerable<string> GetInRole(string roleName)
{
return Employees
.Where(e => e.EmployeeRoles.Any(r => r.Role.Name == roleName))
.Select(e => e.Id.ToString());
}

public bool CheckRole(Guid employeeId, string roleName)
{
return Employees.Any(e => e.Id == employeeId && e.EmployeeRoles.Any(r => r.Role.Name == roleName));
}

public List<Employee> GetWithPaging(string? userName = null, SortDirection sortDirection = SortDirection.Asc, Paging? paging = null)
{
var result = new List<Employee>(Employees);

if (userName != null)
{
result = result.Where(e => e.Name.Contains(userName)).ToList();
}

if (sortDirection == SortDirection.Desc)
{
result = result.OrderByDescending(e => e.Name).ToList();
}
else
{
result = result.OrderBy(e => e.Name).ToList();
}

if (paging != null)
{
result = result.Skip(paging.SkipCount()).Take(paging.PageSize).ToList();
}

return result;
}
}

Save predefined employee entries in ./configs/employees.json. Each role has one employee with the same name. The Guest employee has no roles, the Manager employee has a user role, but will act as a manager in document. The SuperUser has all the roles, so we can exclude roles from testing.

./configs/employees.json
[
{
"Id": "dc114758-d201-488e-ba88-a4a24bb57a3c",
"Name": "SuperUser",
"StructDivisionId": "00000000-0000-0000-0000-000000000000",
"StructDivision": null,
"IsHead": false,
"EmployeeRoles": [
{
"EmployeeId": "dc114758-d201-488e-ba88-a4a24bb57a3c",
"RoleId": "8d378ebe-0666-46b3-b7ab-1a52480fd12a",
"Role": {
"Id": "8d378ebe-0666-46b3-b7ab-1a52480fd12a",
"Name": "Big Boss"
}
},
{
"EmployeeId": "dc114758-d201-488e-ba88-a4a24bb57a3c",
"RoleId": "412174c2-0490-4101-a7b3-830de90bcaa0",
"Role": {
"Id": "412174c2-0490-4101-a7b3-830de90bcaa0",
"Name": "Accountant"
}
},
{
"EmployeeId": "dc114758-d201-488e-ba88-a4a24bb57a3c",
"RoleId": "71fffb5b-b707-4b3c-951c-c37fdfcc8dfb",
"Role": {
"Id": "71fffb5b-b707-4b3c-951c-c37fdfcc8dfb",
"Name": "User"
}
}
]
},
{
"Id": "49c3dc51-462d-488c-ac31-41af8b3a13c0",
"Name": "BigBoss",
"StructDivisionId": "00000000-0000-0000-0000-000000000000",
"StructDivision": null,
"IsHead": false,
"EmployeeRoles": [
{
"EmployeeId": "49c3dc51-462d-488c-ac31-41af8b3a13c0",
"RoleId": "8d378ebe-0666-46b3-b7ab-1a52480fd12a",
"Role": {
"Id": "8d378ebe-0666-46b3-b7ab-1a52480fd12a",
"Name": "Big Boss"
}
}
]
},
{
"Id": "3640c894-3b83-4d45-ade2-d9c3bd4a77ff",
"Name": "Accountant",
"StructDivisionId": "00000000-0000-0000-0000-000000000000",
"StructDivision": null,
"IsHead": false,
"EmployeeRoles": [
{
"EmployeeId": "3640c894-3b83-4d45-ade2-d9c3bd4a77ff",
"RoleId": "412174c2-0490-4101-a7b3-830de90bcaa0",
"Role": {
"Id": "412174c2-0490-4101-a7b3-830de90bcaa0",
"Name": "Accountant"
}
}
]
},
{
"Id": "0a825b20-62d5-4045-a5c3-8a5bdb08bd25",
"Name": "User",
"StructDivisionId": "00000000-0000-0000-0000-000000000000",
"StructDivision": null,
"IsHead": false,
"EmployeeRoles": [
{
"EmployeeId": "0a825b20-62d5-4045-a5c3-8a5bdb08bd25",
"RoleId": "71fffb5b-b707-4b3c-951c-c37fdfcc8dfb",
"Role": {
"Id": "71fffb5b-b707-4b3c-951c-c37fdfcc8dfb",
"Name": "User"
}
}
]
},
{
"Id": "6a3419d7-95a5-4b2b-939c-bd2dba485867",
"Name": "Manager",
"StructDivisionId": "00000000-0000-0000-0000-000000000000",
"StructDivision": null,
"IsHead": false,
"EmployeeRoles": [
{
"EmployeeId": "6a3419d7-95a5-4b2b-939c-bd2dba485867",
"RoleId": "71fffb5b-b707-4b3c-951c-c37fdfcc8dfb",
"Role": {
"Id": "71fffb5b-b707-4b3c-951c-c37fdfcc8dfb",
"Name": "User"
}
}
]
},
{
"Id": "22893a7f-cef7-43b6-99cb-05517f7d2194",
"Name": "Guest",
"StructDivisionId": "00000000-0000-0000-0000-000000000000",
"StructDivision": null,
"IsHead": false,
"EmployeeRoles": []
}
]

Modify TestData class to load and save predefined employees in public List.

./TestData.cs
using Newtonsoft.Json;
using WF.Sample.Business.Model;
using WF.Sample.Tests.Testcontainers;

namespace WF.Sample.Tests;

public static class TestData
{
static TestData()
{
var employeesJson = File.ReadAllText(Path.Combine(ConfigPath, EmployeesConfigName));
var employees = JsonConvert.DeserializeObject<List<Employee>>(employeesJson)
?? throw new InvalidOperationException("Json deserialization error.");

Employees = employees;

var optionsJson = File.ReadAllText(Path.Combine(ConfigPath, OptionsConfigName));
var options = JsonConvert.DeserializeObject<DbOptions>(optionsJson)
?? throw new InvalidOperationException("Json deserialization error.");

var scripts = ScriptNames.Select(s => Path.Combine(ScriptPath, s));

_dbRuntime = new DbRuntime(options, scripts.ToArray());
}

public static async Task StartAsync()
{
await _dbRuntime.StartAsync();
}

public static async Task StopAsync()
{
await _dbRuntime.StopAsync();
}

public static string ConnectionString => _dbRuntime.ConnectionString;
public static List<Employee> Employees { get; }

public const string ConfigPath = "./configs";
public const string OptionsConfigName = "options.json";
private const string EmployeesConfigName = "employees.json";

public const string ScriptPath = "./scripts";
public static readonly string[] ScriptNames =
{
"CreatePersistenceObjects.sql",
"CreateObjects.sql",
"FillData.sql"
};

private static readonly DbRuntime _dbRuntime;
}

WorkflowRuntime

We'll create an instance of WorkflowRuntime using the WF.Sample.Business.Workflow.WorkflowInit class, but as an IDataServiceProvider we'll pass a mock implementation into which we'll place services created on previous steps.

Create a final mockup service: TestPersistenceProviderContainer, which implements the IPersistenceProviderContainer interface.

./Mockups/TestPersistenceProviderContainer.cs
using OptimaJet.Workflow.Core.Persistence;
using OptimaJet.Workflow.DbPersistence;
using WF.Sample.Business.DataAccess;

namespace WF.Sample.Tests.Mockups;

public class TestPersistenceProviderContainer : IPersistenceProviderContainer
{
public TestPersistenceProviderContainer()
{
Provider = new MSSQLProvider(TestData.ConnectionString);
}

public IWorkflowProvider Provider { get; }
}

Create a TestDataServiceProvider that implements the IDataServiceProvider. It will find a suitable type in the switch and return one of our services created earlier.

./Mockups/TestDataServiceProvider.cs
using WF.Sample.Business.DataAccess;

namespace WF.Sample.Tests.Mockups;

public class TestDataServiceProvider : IDataServiceProvider
{
T IDataServiceProvider.Get<T>()
{
return (T) Get(typeof(T));
}

private object Get(Type type)
{
return type.Name switch
{
nameof(IPersistenceProviderContainer) => new TestPersistenceProviderContainer(),
nameof(IEmployeeRepository) => _employeeRepository,
nameof(IDocumentRepository) => _documentsRepository,
_ => throw new ArgumentOutOfRangeException()
};
}

private readonly TestEmployeeRepository _employeeRepository = new(TestData.Employees);
private readonly TestDocumentRepository _documentsRepository = new();
}

And finally, add work with WorkflowRuntime to the TestData class.

./TestData.cs
using Newtonsoft.Json;
using OptimaJet.Workflow.Core.Runtime;
using WF.Sample.Business.DataAccess;
using WF.Sample.Business.Model;
using WF.Sample.Business.Workflow;
using WF.Sample.Tests.Mockups;
using WF.Sample.Tests.Testcontainers;

namespace WF.Sample.Tests;

public static class TestData
{
static TestData()
{
var employeesJson = File.ReadAllText(Path.Combine(ConfigPath, EmployeesConfigName));
var employees = JsonConvert.DeserializeObject<List<Employee>>(employeesJson)
?? throw new InvalidOperationException("Json deserialization error.");

Employees = employees;

var optionsJson = File.ReadAllText(Path.Combine(ConfigPath, OptionsConfigName));
var options = JsonConvert.DeserializeObject<DbOptions>(optionsJson)
?? throw new InvalidOperationException("Json deserialization error.");

var scripts = ScriptNames.Select(s => Path.Combine(ScriptPath, s));

_dbRuntime = new DbRuntime(options, scripts.ToArray());
}

public static async Task StartAsync()
{
await _dbRuntime.StartAsync();
WorkflowInit.Create(new TestDataServiceProvider());
WorkflowRuntime = WorkflowInit.Runtime;
}

public static async Task StopAsync()
{
await _dbRuntime.StopAsync();
}

public static WorkflowRuntime WorkflowRuntime
{
get => _workflowRuntime ?? throw new NullReferenceException("WorkflowRuntime not initialized.");
private set => _workflowRuntime = value;
}

public static IDataServiceProvider DataServiceProvider => WorkflowInit.DataServiceProvider;

public static string ConnectionString => _dbRuntime.ConnectionString;
public static List<Employee> Employees { get; }

//The SchemeCode is taken from init scripts for the database
public const string SchemeCode = "SimpleWF";

public const string ConfigPath = "./configs";
public const string OptionsConfigName = "options.json";
private const string EmployeesConfigName = "employees.json";

public const string ScriptPath = "./scripts";
public static readonly string[] ScriptNames =
{
"CreatePersistenceObjects.sql",
"CreateObjects.sql",
"FillData.sql"
};

private static readonly DbRuntime _dbRuntime;
private static WorkflowRuntime? _workflowRuntime;
}

Now we have a full-fledged environment for running and working with tests, and we can move on to writing the tests themselves. Your project should now appear somewhat like this:

Test structure

Write tests

Before running tests, we need to create a static data in the TestData class. To do this, create an AssemblyInitializer class in the ./Tests folder. Learn more about MSTest attributes in the Microsoft documentation.

./Tests/AssemblyInitializer.cs
using Microsoft.VisualStudio.TestTools.UnitTesting;

namespace WF.Sample.Tests.Tests;

[TestClass]
public static class AssemblyInitializer
{
[AssemblyInitialize]
public static async Task AssemblyInit(TestContext context)
{
await TestData.StartAsync();
}

[AssemblyCleanup]
public static async Task AssemblyCleanup()
{
await TestData.StopAsync();
}
}

VacationRequest tests

Now we can create a test class for the VacationRequest scheme. It's best to divide test methods into classes, where the class is responsible for one scheme, and the method for a logical element. Create a class VacationRequestTests, for quick access to WorkflowRuntime, take it to the properties.

./Tests/VacationRequestTests.cs
using Microsoft.VisualStudio.TestTools.UnitTesting;
using OptimaJet.Workflow.Core.Runtime;
using WF.Sample.Tests.Mockups;

namespace WF.Sample.Tests.Tests;

[TestClass]
public class VacationRequestTests
{
public WorkflowRuntime Runtime => TestData.WorkflowRuntime;

[TestMethod]
public async Task SumSavingTest(double sum)
{
throw new NotImplementedException();
}

[TestMethod]
public async Task SchemeRouteTest(int sum, params string[] commandNames)
{
throw new NotImplementedException();
}

[TestMethod]
public async Task AvailableCommandsTest(string employee, params string[] commandNames)
{
throw new NotImplementedException();
}
}

Create an extension to instantiate processes using a TestDocument object and immediately add it to our TestDocumentRepository mockup.

./Tests/Extensions.cs
using OptimaJet.Workflow.Core.Runtime;
using WF.Sample.Business.DataAccess;
using WF.Sample.Tests.Mockups;

namespace WF.Sample.Tests.Tests;

public static class Extensions
{
public static async Task CreateInstanceAsync(this WorkflowRuntime runtime, TestDocument document)
{
var documents = TestData.DataServiceProvider.Get<IDocumentRepository>();
documents.InsertOrUpdate(document.ToDocument());

await runtime.CreateInstanceAsync(TestData.SchemeCode, document.Id);
}
}

SumSavingTest

The first test will check that the Sum is saved in the process parameters. The test is data-driven and through the DataRow attribute, you can pass different options for the Sum. In the test we create a document, specifying the author, who has absolute permissions, and Sum, then we make an instance by our extension, get the Sum using GetParameterAsync, and check it with Assert.AreEqual.

./Tests/VacationRequestTests.cs
[TestMethod]
[DataRow(1.0)]
[DataRow(10000.0)]
[DataRow(100000000000.0)]
[DataRow(1.9)]
[DataRow(1.99999)]
[DataRow(1.999999999999)]
public async Task SumSavingTest(double sum)
{
var document = new TestDocument(TestData.Employees.First(e => e.Name == "SuperUser")) {Sum = Convert.ToDecimal(sum)};
await Runtime.CreateInstanceAsync(document);

var instance = await Runtime.GetProcessInstanceAndFillProcessParametersAsync(document.Id);

var sumParameter = await instance.GetParameterAsync("Sum");

Assert.AreEqual(Convert.ToDecimal(sum), sumParameter.Value);
}

SchemeRouteTest

In the second test, we'll check the sequence of commands execution, or the route, which will change depending on the specified Sum. Similar to the prior test, we use a DataRow to pass parameters and SuperUser as the author, to which we previously assigned all roles. After instantiation, the commands got by GetAvailableCommandsAsync are sequentially executed by ExecuteCommandAsync. With the help of Assert.IsTrue(result.WasExecuted) we check the success of the command execution, and at the end of the loop we make sure that the process has taken the finalized status.

./Tests/VacationRequestTests.cs
[TestMethod]
[DataRow(10, "StartSigning", "Approve", "Paid")]
[DataRow(1000, "StartSigning", "Approve", "Approve", "Paid")]
public async Task SchemeRouteTest(int sum, params string[] commandNames)
{
var document = new TestDocument(TestData.Employees.First(e => e.Name == "SuperUser")) {Sum = sum};
await Runtime.CreateInstanceAsync(document);

foreach (var commandName in commandNames)
{
var commands = await Runtime.GetAvailableCommandsAsync(document.Id, document.Author.Id.ToString());
var command = commands.First(c => c.CommandName == commandName);
var result = await Runtime.ExecuteCommandAsync(command, document.Author.Id.ToString(), document.Author.Id.ToString());
Assert.IsTrue(result.WasExecuted);
}

var status = await Runtime.GetCurrentStateAsync(document.Id);
Assert.AreEqual("RequestApproved", status.Name);
}

AvailableCommandsTest

In earlier tests, we used an employee who had all the roles. In the third test, we'll check the work of the roles themselves. To do this, we create a document, assign User as the author, and Manager as the manager. Role names and their associated commands are passed as parameters. We're checking for commands coming from activity ManagerSigning, so the first thing we do is jump into it by running StartSigning. Next, we get the available commands for the specified employee and do an outer join with the sample commands. If the command lists are equivalent, we get an empty list, what we check with Assert.

./Tests/VacationRequestTests.cs
[TestMethod]
[DataRow("BigBoss")]
[DataRow("Accountant")]
[DataRow("User")]
[DataRow("Manager", "Approve", "Reject")]
[DataRow("Guest")]
public async Task AvailableCommandsTest(string employee, params string[] commandNames)
{
var document = new TestDocument(TestData.Employees.First(e => e.Name == "User"))
{
Manager = TestData.Employees.First(e => e.Name == "Manager")
};

await Runtime.CreateInstanceAsync(document);

var initCommand = (await Runtime.GetAvailableCommandsAsync(document.Id, document.Author.Id.ToString()))
.First(c => c.CommandName == "StartSigning");
await Runtime.ExecuteCommandAsync(initCommand, document.Author.Id.ToString(), document.Author.Id.ToString());

var assignee = TestData.Employees.First(e => e.Name == employee);
var availableCommandNames = (await Runtime.GetAvailableCommandsAsync(document.Id, assignee.Id.ToString()))
.Select(c => c.CommandName).ToList();

var outerJoin = commandNames
.Union(availableCommandNames).Distinct()
.Except(commandNames.Intersect(availableCommandNames));

Assert.AreEqual(0, outerJoin.Count());
}

Now you can run tests from your IDE or console. A running docker is all you need to run tests.

dotnet test 

Conclusion

You can build up your own scheme testing using this example. But it isn't necessary to use the MSTest framework and Testcontainers, you can build the testing architecture that you are used to. The tests are simple to write, isolated, and reliable, as you can see from the guide. Additionally, testing any aspect of business logic is completely free.