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
- Docker.
- .NET 6 SDK.
- JetBrains Rider or Visual Studio.
- Terminal.
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:
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.
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;
Build and run
Open the solution. In the appsettings.json
file, change the connection string to a new one.
{
"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.
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.
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.
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.
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.
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; }
}
Migrations
Create MigrationExtensions
class which will contain the path to the scripts.
namespace WF.Sample.Business.Migrations
{
public static class MigrationExtensions
{
public static string GetEmbeddedPath(string fileName)
{
return $"WF.Sample.Business.Scripts.{fileName}";
}
}
}
Then, we will create two migration scripts. Please note that, due to the differences in the various databases, the actual implementation may vary. You can find an example implementation for another database in the samples.
using FluentMigrator;
namespace WF.Sample.Business.Migrations
{
[Migration(2000010)]
public class Migration2000010CreateObjects : Migration
{
public override void Up()
{
Execute.EmbeddedScript(MigrationExtensions.GetEmbeddedPath("CreateObjects.sql"));
}
public override void Down()
{
}
}
}
using FluentMigrator;
namespace WF.Sample.Business.Migrations
{
[Migration(2000020)]
public class Migration2000020FillData : Migration
{
public override void Up()
{
Execute.EmbeddedScript(MigrationExtensions.GetEmbeddedPath("FillData.sql"));
}
public override void Down()
{
}
}
}
Finally, you need to place the scripts for the Createobject.sql
and FillData.sql
migrations in the ./Scripts
directory and add them as
project resources.
<ItemGroup>
<EmbeddedResource Include="Scripts\CreateObjects.sql" />
<EmbeddedResource Include="Scripts\FillData.sql" />
</ItemGroup>
Testcontainer runtime
Create the DbRuntime
class to run and control the Testcontainer.
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.
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
using DotNet.Testcontainers.Builders;
using Microsoft.Data.SqlClient;
using OptimaJet.Workflow.Core.Runtime;
using OptimaJet.Workflow.DbPersistence;
using OptimaJet.Workflow.Migrator;
using WF.Sample.Business.Migrations;
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();
InitDatabase(ConnectionString);
}
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 static void InitDatabase(string connectionString)
{
var mssqlProvider = new MSSQLProvider(connectionString);
new WorkflowRuntime()
.WithPersistenceProvider(mssqlProvider)
.RunMigrations()
.RunCustomMigration(typeof(Migration2000010CreateObjects).Assembly);
}
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
folder is always copied to the build directory. To
do this, add the following settings:
<ItemGroup>
<None Include="configs\**" CopyToOutputDirectory="Always" LinkBase="configs\"/>
</ItemGroup>
Add the options.json
file in configs
folder.
{
"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.
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.");
_dbRuntime = new DbRuntime(options);
}
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";
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.
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.
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
.
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.
[
{
"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.
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.");
_dbRuntime = new DbRuntime(options);
}
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";
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.
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.
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.
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.");
_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";
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:
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.
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.
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.
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
.
[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.
[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.
[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.