Precept 0.10.0

Files

A suite that tests an import writes the spreadsheet the system under test is going to read; a suite that tests an export reads the one it produced back and checks the rows. Precept.Files is those two motions, for Excel workbooks, CSV files, JSON and XML documents, without a test knowing an OpenXML part from a shared string.

public void ConfigureServices(IServiceCollection services, PreceptSettings settings) =>
    services.AddPreceptFiles();
[When("the clients are imported from a spreadsheet")]
public async Task ImportClients(DataTable table)
{
    var path = await Excel.WriteAsync("clients.xlsx", table.Rows, sheet: "Clients");
    await (await Browser.PageAsync()).SetInputFilesAsync("input[type=file]", path);
}

[Then("the export should contain")]
public async Task ExportContains(DataTable table)
{
    var export = Excel.Open("export.xlsx").Sheet("Clients");
    await Assert.That(export).ToContainRowsAsync(table.Rows);
}

The module never references Reqnroll. It takes rows, and a row is anything dictionary-shaped — a Reqnroll table.Rows, a Db.QueryAsync result, a list of dictionaries — or an object, whose public properties are the columns. One method takes all of them, so the step above works the same in a suite with no feature files.

Every type hands out what is underneath: a sheet's Worksheet is ClosedXML's IXLWorksheet, an XML document's Document is an XDocument, a JSON document's Root is a JsonNode. Formatting, formulas, named ranges, LINQ to XML — nothing is fenced off.

The three tabular formats — a sheet, a CSV file, a JSON array of objects — share one model and one set of row assertions: the first row is the header, rows are added under it by column name, and ToContainRowsAsync(table.Rows) reads the same against any of them.

Where files go#

A bare file name — clients.xlsx — is written into the running test's artifact directory, the directory its screenshots and logs already go to. It is unique to the test, so parallel scenarios writing import.xlsx do not overwrite each other, and the file is attached to the test's result: it reaches the TRX, the test explorer and every reporter. The path SaveAsync returns is the one to hand to an upload.

A file is read from that directory first, since the file a test most often reads back is the one it just wrote, then from beside the test binary, where a fixture with CopyToOutputDirectory lands, then from the current directory. A rooted path is used as it is. A file found nowhere fails naming every place it looked.

Outside a test — a run-level hook seeding an environment — there is no artifact directory, so a bare name is relative to the current directory, and nothing is attached.

Excel#

Writing#

Excel.WriteAsync is the one-liner: a new workbook, one sheet, the rows, saved. The columns are the rows' keys in the order first seen, or the objects' properties in declaration order; the header is row one, in bold.

var path = await Excel.WriteAsync("clients.xlsx", table.Rows, sheet: "Clients");
var path = await Excel.WriteAsync("orders.xlsx", await Db.QueryAsync("select * from orders"));
var path = await Excel.WriteAsync("vehicles.xlsx", [new Vehicle { Registration = "AB12 CDE", Seats = 5 }]);

A workbook with more in it is built up and saved once:

using var workbook = Excel.New();

workbook.Sheet("Clients").AddRows(clients);
workbook.Sheet("Orders")
    .Header("Id", "Total", "Placed")
    .AddRow(1, 42.50m, new DateTime(2026, 1, 5))
    .AddRow(new { Id = 2, Total = 7m });

var path = await workbook.SaveAsync("import.xlsx");

Sheet(name) returns the sheet, creating it if the workbook has none of that name; Sheet() is the first one. Header lays out the columns of an empty sheet before any rows arrive, for an importer that wants columns the rows do not all carry. AddRow takes an object or a dictionary, placed by column name, or positional cells, placed left to right. ToBytes() is the workbook as .xlsx bytes, for a request body that never needs a file.

Every cell of a Gherkin table is a string, and a sheet of text-typed numbers is what an importer rejects. So a text cell that unambiguously spells a number, a date or a boolean is written as one: 42, -1.5, true, 2026-03-01, 2026-03-01T09:30:00. The rule is deliberately narrow — 007 stays text, because an identifier that happens to be digits must; so do 1,000, 1e5 and any date not written ISO-style. Files:InferCellTypes turns it off for a suite whose importer wants text. A value that is already typed — a decimal from a query, a DateTime on an object — is written as what it is.

Appending#

The reason this is a module and not a File.WriteAllBytes: a row added to a sheet the system under test produced goes under the columns it names, in the sheet's order, without the test knowing that order.

using var workbook = Excel.Open("export.xlsx");

workbook.Sheet("Clients").AddRow(new { Email = "grace@example.test", FirstName = "Grace", Id = 2 });

await workbook.SaveAsync();   // back where it was opened from

Column names match loosely: First name, first_name and FirstName are one column, so a table can name a column the way a person would and a property the way C# does. A name the header does not have becomes a new column on the right rather than an error — which is the behaviour a test adding a field wants and the behaviour a typo in a table header gets, so ToHaveColumnsAsync is the check for the second.

Reading#

var rows = Excel.Read("export.xlsx", "Clients");           // IReadOnlyList<IReadOnlyDictionary<string, object?>>
var clients = Excel.Read<Client>("export.xlsx", "Clients");   // columns matched to properties by name

using var workbook = Excel.Open("export.xlsx");
var sheet = workbook.Sheet("Clients");

sheet.Columns;        // the header
sheet.RowCount;       // rows below it
sheet.Rows();         // every row, as a case-insensitive column/value map
sheet.Rows<Client>(); // every row, as an object
sheet.Value("B2");    // one cell

Cells come back as the sheet holds them: a number as a double — Excel has no other kind — a date as a DateTime, a boolean as a bool, a blank as null. Rows<T>() converts on the way into a property, so a double lands in an int, a date in a DateOnly and text in an enum. Excel.Open also takes a Stream or bytes, for a download that never touched the disk.

Assertions#

var export = Excel.Open("export.xlsx");
var sheet = export.Sheet("Clients");

await Assert.That(export).ToHaveSheetAsync("Clients");
await Assert.That(sheet).ToHaveColumnsAsync("Id", "Name", "Email");
await Assert.That(sheet).ToHaveRowCountAsync(3);
await Assert.That(sheet).ToHaveValueAsync("B2", "Acme");
await Assert.That(sheet).ToContainRowAsync(new { Name = "Acme", Email = "acme@example.test" });
await Assert.That(sheet).ToContainRowsAsync(table.Rows);

Cells are compared the way a data table compares them, by the text each would show: a cell the sheet holds as the number 3 matches the | 3 | a scenario wrote, 12.50 matches 12.5, and a date matches its ISO spelling. ToContainRowAsync compares only the columns the expected row names, and ToContainRowsAsync allows rows beyond the ones expected — pair it with ToHaveRowCountAsync when the sheet must hold exactly those. A failure lists the rows that were missing and the rows the sheet had.

CSV#

The model is a sheet's, minus the types: the first record is the header, rows go under it by column name, every cell is text.

var path = await Csv.WriteAsync("clients.csv", table.Rows);
var rows = Csv.Read("export.csv");                          // IReadOnlyList<IReadOnlyDictionary<string, string>>
var clients = Csv.Read<Client>("export.csv");               // text converted into each property's type

var file = Csv.Open("export.csv", delimiter: ";");
file.AddRow(new { Name = "Acme", Email = "acme@example.test" });   // under the columns it has
await file.SaveAsync();

await Assert.That(file).ToHaveColumnsAsync("Name", "Email");
await Assert.That(file).ToContainRowsAsync(table.Rows);

Reading and writing follow RFC 4180: a field holding the delimiter, a quote or a line break is quoted and a quote inside one is doubled, both undone on the way back in; records end in CRLF; a byte-order mark, either line ending and a ragged record are all read. The delimiter is Files:CsvDelimiter — a comma — unless the call names one, so a suite in a locale whose exports use a semicolon changes it once. Csv.New(), Csv.Parse(text) and Csv.Open(stream) are there for a file that is built up, or arrives as a download.

JSON#

Building#

var order = Json.New()
    .Set("customer.id", 42)                        // objects on the way are created
    .Set("lines", Json.Array(table.Rows))          // [{ "sku": "A1", "quantity": 2 }, …]
    .Add("tags", "rush");                          // appended to the array, created if missing

await Rest.Post("/orders").WithBody(order.ToText(), "application/json").SendAsync();
var path = await order.SaveAsync("order.json");

Json.Array(rows) is the data-table shape: one object per row, property names as the columns are. A row's cells are text by necessity, so they are the one place text is inferred into a number, a boolean or a date under the same rule as a sheet'sSet("id", "42") writes the string it was given, and Set("id", 42) a number. Json.From(value) makes a document from an object, serialised with the web defaults (camelCase, as Precept.Api sends one), a dictionary, or rows. A JsonNode built by hand goes anywhere a value does.

Querying#

Paths are the ones Precept.Api uses for ToHaveJsonValueAsync: property names joined by dots, an array element by its index, with or without a leading $.data.items.0.name or data.items[0].name. A property is matched exactly first and by any case second, so a step can say Status for a status.

var confirmation = Json.Parse(response.Body);    // or Json.Load("confirmation.json"), or a Stream

confirmation.Value("confirmation.order");        // "42" — a value as its text, null for nothing
confirmation.Values("confirmation.lines");       // each element of the array, as JSON
confirmation.Count("confirmation.lines");        // an array's length, an object's property count
confirmation.Exists("confirmation.note");        // true for a JSON null too
confirmation.Get<Line>("confirmation.lines[0]"); // a part deserialised
confirmation.As<Confirmation>();                 // the whole thing
confirmation.At("confirmation.lines");           // a part as a document of its own, over the same tree
confirmation.Rows("confirmation.lines");         // an array of objects as rows

Changing#

confirmation.Set("confirmation.status", "rejected")
            .Add("confirmation.lines", new { sku = "C3", quantity = 1 })
            .Add("confirmation", new { reviewed = true })      // merged into the object
            .Remove("confirmation.lines[0]");

await confirmation.SaveAsync();

An index may be at most one past the end, which appends; further is an error. A Remove of something that is not there throws rather than passing quietly.

Assertions#

await Assert.That(confirmation).ToHaveNodeAsync("confirmation");
await Assert.That(confirmation).ToHaveCountAsync("confirmation.lines", 2);
await Assert.That(confirmation).ToHaveValueAsync("confirmation.order", 42);
await Assert.That(confirmation).ToBeEquivalentToAsync(expectedJson);

await Assert.That(confirmation.At("confirmation.lines")).ToHaveRowCountAsync(2);
await Assert.That(confirmation.At("confirmation.lines")).ToContainRowsAsync(table.Rows);

ToBeEquivalentToAsync ignores property order and formatting and nothing else. The row assertions apply to a document whose root is an array of objects, which is what At(path) narrows to.

XML#

Building#

var order = Xml.New("order")
    .Add("/order", new XElement("customer", "acme"))
    .Add("/order", Xml.Elements("line", table.Rows));    // <line><sku>A1</sku><quantity>2</quantity></line> per row

await Rest.Post("/orders").WithBody(order.ToText(), "application/xml").SendAsync();
var path = await order.SaveAsync("order.xml");

Xml.Elements(name, rows) is the data-table shape: one element per row, a child element per column holding the cell as text — or an attribute per column with asAttributes: true. A column with a space in its name becomes an element without one, since XML allows no other. Anything LINQ to XML builds goes into Add as it would into XElement.Add, and Xml.New(XElement) or Xml.From(XDocument) wraps a document built entirely that way.

Querying#

var confirmation = Xml.Parse(response.Body);        // or Xml.Load("confirmation.xml"), or a Stream

confirmation.Value("/confirmation/@order");         // "42" — the first match, or null for none
confirmation.Values("//line/@sku");                 // every match, in document order
confirmation.Value("count(//line)");                // "2" — a function result reads too
confirmation.Count("//line");
confirmation.Exists("//error");
confirmation.Element("//line[1]");                  // an XElement, for LINQ to XML from there

Every prefix the document declares works in an XPath as it stands: a document that says xmlns:soap="…" answers //soap:Body with nothing registered. A default namespace is the trap — it has no prefix, XPath 1.0 has no way to name it, and a path that looks right finds nothing:

var order = Xml.Load("order.xml").WithDefaultNamespace("o");   // binds "o" to the root's xmlns
order.Value("/o:order/o:id");

WithNamespace(prefix, uri) binds any other. When a Set, Add or Remove selects nothing on a document with a default namespace and the path has no prefix, the exception says that this is the likely reason; so does a failing assertion.

Changing#

order.Set("/order/@id", 43)                  // every element or attribute selected; a number is written as text
     .Set("//status", "accepted")
     .Add("/order", new XElement("note", "rush"))
     .Remove("//line[@sku='A1']");

await order.SaveAsync();                     // back where it was loaded from

A path that selects nothing throws rather than doing nothing — a test that meant to change a value and silently changed none would then pass for the wrong reason.

Validating#

var errors = order.SchemaErrors("schemas/order.xsd");      // empty for a valid document
await Assert.That(order).ToBeValidAgainstAsync("schemas/order.xsd");

A schema path resolves the way a file to read does, so schemas/order.xsd beside the test binary is found by that name.

Assertions#

await Assert.That(confirmation).ToHaveNodeAsync("//confirmation");
await Assert.That(confirmation).ToHaveCountAsync("//line", 2);
await Assert.That(confirmation).ToHaveValueAsync("/confirmation/@order", 42);
await Assert.That(confirmation).ToBeEquivalentToAsync(expectedXml);
await Assert.That(confirmation).ToBeValidAgainstAsync("schemas/confirmation.xsd");

ToBeEquivalentToAsync compares what two documents say: indentation, comments, the XML declaration and attribute order are ignored, element order and text are not. A failure shows both documents in that normalised form. Normalised() on a document is the same view, for a comparison of your own.

Settings#

{
  "Files": {
    "InferCellTypes": true,
    "AttachWrittenFiles": true,
    "CsvDelimiter": ","
  }
}

The section is registered by AddPreceptFiles(), so a project that never installed the module is never offered it. Inside a test, every entry point — Excel, Csv, Json, Xml — checks for that registration before doing anything else, so a suite that forgot it hears so on the first file a step touches, whichever kind that is, with the call to add. Outside a test — a run-level hook, a project's own unit tests — there is no container to have registered anything, and the defaults apply. AttachWrittenFiles off keeps a large generated file out of every report while still writing it where a test can find it. Like every setting, each can be overridden as PRECEPT_FILES__INFERCELLTYPES, PRECEPT_FILES__ATTACHWRITTENFILES and PRECEPT_FILES__CSVDELIMITER — see configuration.

Precept 0.10.0 · MIT · © 2026

Esc