Repository navigation
Modeling Recipes
The model syntax is small; most of the work is deciding what to model. These recipes cover patterns that come up often, especially when modeling existing code. They're suggestions, not rules: the best model is the one that makes the scenarios you need to test clear. cfweaver help recipes prints this page.
A step is a point where the code can go more than one way. Code that always runs the same way, like logging or building a request, doesn't need a step unless it can fail in a way you want to test.
Each scenario's conditions are what its test has to set up, so a good model gives every scenario different conditions, and the Condition column reads as a test plan. Give every result a condition except the one its step normally takes, and don't give two results on the same step the same condition.
# Delete Item
* Authorize: Allowed | Denied => 403 ? the caller doesn't own the item
* Delete: Deleted => 204 | Missing => 404 ? no item has this id | Failed => 500 ? the database errorsThis matters most on a step that calls a sub-operation. The caller's table shows only the conditions written on its own results, so give each returned value a condition that sums up what causes it, such as Invalid => 401 ? the request has no valid token; the sub-operation's own table has the details. CFWeaver warns when two scenarios in an operation have the same conditions.
Give each step that can throw a single Failed result, rather than one per exception type, unless the code handles different exceptions differently. If the code catches a timeout and returns 504, the timeout is its own result.
# Save Item
* Validate: Valid | Invalid => 400 ? the body is missing a name
* Save: Saved => 201 | Conflict => 409 ? an item with the same name exists | Failed => 500 ? the database throwsWhen several inputs can be invalid, order the steps the way the code checks them. A request with both a bad limit and a bad sort follows the first failure, so the table has one row for each check rather than one for every combination of invalid inputs.
# Search Items
* ValidateLimit: Valid | TooLarge => 400 ? limit is over 100
* ValidateSort: Valid | Unknown => 400 ? sort names a field that doesn't exist
* Search: Found => 200 | Failed => 500 ? the search index errorsIf the code collects every validation error before responding, a single step is enough: Validate: Valid | Invalid => 400.
A loop that stops at its first failure has the same outcomes as a single item: every item succeeds, or one of them fails. Model one item as an operation of its own, and give the step that loops the same name, so it's read as calling it as a sub-operation.
# Import Items
* ParseFile: Parsed | Malformed => 400 ? the file isn't valid CSV
* ImportRow: Imported | Rejected => 422 ? a row has no name or duplicates another
* Commit: Committed => 200 | Failed => 500 ? the transaction fails
# ImportRow
* Validate: Valid | MissingName => Rejected ? the row has no name
* Insert: Inserted => Imported | Duplicate => Rejected ? a row with the same name existsIf the loop carries on past failures, model the outcomes the code acts on instead, such as AllImported and SomeRejected. If two steps loop over the same kind of item, such as a list of filters and a list of exclusions parsed the same way, name the operation in brackets after each step's name instead: * BindExclude [Parse Filter]: Parsed | Invalid => 400 ? an exclude token is malformed.
Model a retry by its outcomes, not its attempts: success on the first attempt, success after a retry, and retries exhausted. Put the number of attempts and the delays in the description.
# FetchPrice
Retries up to 3 times, waiting 200 ms between attempts.
* CallService: FirstAttempt => Price ? the first attempt succeeds | AfterRetry => Price ? an attempt fails but a retry succeeds | Exhausted => Unavailable ? every attempt failsState that a test has to set up before the request, such as an open circuit breaker, a cached item or a feature flag, goes in the condition of the result it causes. Don't model how that state came about, such as the failures that opened the circuit.
# Get Recommendations
* Breaker: Closed | Open => 503 ? the circuit breaker is open
* CallService: Returned => 200 | Failed => 503 ? the recommendation service errorsBehavior you don't model, such as routing, deserialization or serialization handled by a framework, belongs in the operation's description as an assumption. CFWeaver copies descriptions into its output, so readers can see what the scenarios take for granted.
# GET /items/{id}
Assumes the framework has matched the route and parsed `id` as an integer; other routes return 404 before this code runs.
* Authorize: Allowed | Denied => 403 ? the caller can't read items
* Lookup: Found => 200 | Missing => 404 ? no item has this idA caller sees only the value a sub-operation returns, not how it got there. Below, FetchItem returns Unavailable both when the circuit breaker is open and when the service errors. That's fine, because the endpoint does the same thing in both cases. The endpoint's condition for Unavailable sums up both causes, and FetchItem's own table lists them separately.
# GET /items/{id}
* FetchItem: Found => 200 | NotFound => 404 ? the service has no item with this id | Unavailable => 503 ? the service errors or its circuit breaker is open
# FetchItem
* Breaker: Closed | Open => Unavailable ? the circuit breaker is open
* CallService: Ok => Found | Missing => NotFound ? the service returns 404 | Error => Unavailable ? the service returns 500If the endpoint treated them differently, for example by passing on the service's status code, which an open breaker doesn't have, FetchItem would need a separate return value for each case. Sharing one value would let the model describe paths the code can't take.
Sometimes a sub-operation produces something that's only checked later, such as text that the caller parses afterwards. If some of the sub-operation's paths always produce something that the later check rejects, give those paths their own return value, so the caller can send them straight to the failure.
# Bind Filter
* FilterToken: Text | Blank => 400 ? the token's value is blank, which never deserializes | Malformed => 400 ? the token has no colon
* Deserialize: Valid => 200 | Invalid => 400 ? the value has the wrong type for its property
# FilterToken
* Split: Split | Missing => Malformed ? the token has no colon
* Value: Present => Text | Empty => Blank ? the token's value is blankFilterToken doesn't check its text; it only builds the text that Deserialize checks later. A blank value always builds text that fails to deserialize. If FilterToken returned Text for it too, Bind Filter's table would include a scenario where a blank value deserializes successfully, which the code can't do. Returning Blank separately lets Bind Filter send it straight to 400.
CFWeaver joins the conditions along each path with "and", but it doesn't check that they can all be true at once. When one result rules out a later step, branch past that step so the impossible path isn't in the table.
# Upsert Item
* Lookup: Found | Missing = Insert ? no item has this id
* Update: Updated => 200 | Conflict => 409 ? the item changed since it was read
* Insert: Inserted => 201Without = Insert, the table would include scenarios that update an item that doesn't exist, and CFWeaver would warn that no path reaches Insert.