Test data
Almost every suite needs records to exist before a scenario can start: a user to sign in as, a
client to place an order for, a vehicle to book in. Precept.TestData is where those live, so they
stop being string literals scattered through step definitions.
public void ConfigureServices(IServiceCollection services, PreceptSettings settings) =>
services.AddPreceptTestData();
// testdata.json, beside the test binary
{
"users": {
"admin": { "username": "admin@acme.test", "password": "{{env:ADMIN_PASSWORD}}" }
},
"clients": {
"new": { "name": "Acme {{unique}}", "email": "acme-{{unique}}@acme.test" }
}
}
var admin = TestData.Get<TestUser>("admin"); // users:admin
var van = TestData.Get<Vehicle>("vehicles:van"); // or by full path
var password = TestData.Value("users:admin:password");
TestUser reads users, Vehicle reads vehicles, Company reads companies: the set is the
plural of the type name, with a Test prefix and a Data/Dto/Model suffix dropped. Name it
yourself with [TestDataSet("people")] on the type, or data.Map<Person>("users") at registration.
Lookups are case-insensitive, so a file written in lower case matches.
The module's own types live in the Precept namespace, so a step definition that already uses
Precept needs no further using.
Data files must reach the output directory:
<ItemGroup>
<None Update="testdata*.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
Environment-specific data#
testdata.json is overlaid by testdata.{environment}.json and then by PRECEPT_TESTDATA__*
environment variables, the same order Precept uses for settings, and against the same environment —
see Environments for how that is chosen. An overlay only has to carry what differs:
// testdata.staging.json
{ "users": { "admin": { "username": "admin@staging.acme" } } }
The password is a different matter, and this is why the environment variables exist:
PRECEPT_TESTDATA__USERS__ADMIN__PASSWORD='...'
That sets users:admin:password without it ever being written down in the repository. __ becomes
the : separator, exactly as PRECEPT_WEB__BASEURL works for settings.
Load more files — one per domain area, say — with data.AddJsonFile("vehicles.json"), which brings
its own vehicles.{environment}.json overlay along. TestData:Files and TestData:Directory in
precept.json do the same from configuration; TestData:LeaseTimeoutMilliseconds is there too.
Dynamic values#
Anything in a data file may carry {{token}} placeholders, expanded when the value is read rather
than when the file is loaded. A suite that inserts the same email address on every run fails on the
second one; this is the answer to that.
| Token | Gives |
|---|---|
{{unique}} |
A short id, the same everywhere in one scenario and different in the next. |
{{unique:buyer}} |
A second, independent one, for when a scenario needs two. |
{{guid}}, {{guid:N}} |
A GUID, optionally in a .NET format. |
{{now}}, {{now:yyyy-MM-dd}}, {{now:+2d:HH:mm}}, {{today}}, {{yesterday}}, {{tomorrow}} |
A UTC timestamp, optionally shifted by d, h, m, s or y, and formatted. |
{{random}}, {{random:1000-9999}} |
A number, optionally in an inclusive range. |
{{digits:6}}, {{letters:8}}, {{alphanumeric:10}} |
Random text of that length. |
{{env:BUILD_ID}}, {{env:BUILD_ID\|local}} |
An environment variable, with an optional fallback. |
{{setting:Api:BaseUrl}} |
A value from precept.json. |
{{ref:users:admin:username}} |
Another test data value, so two records agree. |
Add your own for whatever the domain needs — a VIN with a valid check digit, a national insurance number, an IBAN:
services.AddPreceptTestData(data => data.AddToken("vin", c => Vin.Generate(c.Argument)));
Stability is what makes these usable. Within one scenario, Get<Client>("new") returns the same
client every time it is asked, so the {{unique}} in the name and the one in the email agree and
every step sees what the first one created. New<Client>("new") deliberately breaks that: it reads
another one, with its own generated values, which is how a scenario gets two different customers.
A retry generates fresh values, because a test that failed on a duplicate key needs a rerun to stop
colliding with what it already inserted.
Data the tests have to create#
Reading a template only goes so far — most data has to exist in the system under test. A factory says how to put it there and how to take it away:
services.AddPreceptTestData(data => data.Factory<Client>(
create: async (_, client) =>
{
var created = await Rest.Post("/clients").WithJsonBody(client).SendAsync();
await Assert.That(created).ToHaveStatusAsync(HttpStatusCode.Created);
return created.Json<Client>();
},
delete: async (_, client) => await Rest.Delete($"/clients/{client.Id}").SendAsync()));
var client = await TestData.CreateAsync<Client>("new");
The template comes from clients:new, the factory creates it through whatever the suite already
uses — an API call, a database insert, a gRPC request — and the delete runs when the test ends,
after the [AfterTest] hook and whatever the verdict was.
| Call | Lifetime |
|---|---|
CreateAsync<T>(key) |
One per call, removed when the test ends. |
GetOrCreateAsync<T>(key) |
One per test, however many steps ask for it. |
SharedAsync<T>(key) |
One per run, removed when the run ends. Treat it as read-only: parallel tests share it. |
Pooled data#
Some data cannot be generated and cannot be shared: three pre-provisioned accounts on a partner system, a handful of licensed test vehicles. Declare that set as a pool and a scenario takes one for its own:
{ "drivers": [ { "username": "driver-one" }, { "username": "driver-two" } ] }
var driver = await TestData.LeaseAsync<TestUser>("drivers");
await signIn.SignInAsync(driver.Value.Username, driver.Value.Password);
Nothing else in the run holds that item until the lease is released, which happens when the test
ends — a scenario that throws does not take an account down with it. Dispose the lease to give it
back sooner. When every item is in use, the next test waits, and gives up after
TestData:LeaseTimeoutMilliseconds with an inconclusive verdict rather than a failure: running out
of accounts is an environment problem, not a defect in the code under test.
From Gherkin#
Steps refer to data by name, so the feature file says what the scenario means rather than what the credentials are:
Given I am signed in as the "admin" user
When I create a client
Then the client should appear on the dashboard
[Binding]
public class ClientSteps(SignInPage signIn)
{
[Given("I am signed in as the {string} user")]
public Task SignIn(string key)
{
var user = TestData.Get<TestUser>(key);
return signIn.SignInAsync(user.Username, user.Password);
}
[When("I create a client")]
public Task CreateClient() => TestData.GetOrCreateAsync<Client>("new");
}
TestData.Resolve("buyer-{{unique}}@acme.test") expands tokens in a string that came from the
feature file itself, for the cases where the value belongs in the scenario rather than in a data
file.