Skip to contents

A generated wrapper can call the right helper and still return the wrong result or throw afterward. Fixed contract tests check successful completion, exact helper arguments, call order/count, and the final result. Write expectations from the reviewed API/helper contract, independently of generated wrapper text.

This standalone example creates a temporary client and runs its generated test offline. Install httr2 and testthat first if they are not available.

1. Prepare a client with one selected operation

schema <- system.file('catalogue/schema.json', package = 'specmill', mustWork = TRUE)
root <- tempfile('tested-client-')
created <- specmill::initialize_client(
  root, schema, package = 'testedclient', title = 'Tested Catalogue Client',
  author = list(given = 'Example', family = 'Maintainer', email = 'you@example.org'),
  license = 'MIT + file LICENSE', base_url = 'https://api.example.org'
)
service_path <- file.path(root, 'apis/default.yml')
service <- yaml::read_yaml(service_path)
service$schemas$files <- as.list(service$schemas$files)
service$selection <- list(include = list('GET /items/{item_id}'))
yaml::write_yaml(service, service_path)

For your real client, use its existing root and selection policy. Do not narrow selection simply to hide missing tests. Here the teaching client intentionally has just get_item().

2. Save independent fixed expectations

An RDS contract preserves R types and classes. calls is an ordered sequence; each entry contains a helper name, its expected arguments, and its mock response.

contracts <- list(
  get_item = list(
    inputs = list(item_id = 'item-1', language = 'en'),
    calls = list(list(
      helper = 'api_request',
      arguments = list(
        method = 'GET', path = '/items/{item_id}',
        path_params = list(item_id = 'item-1'),
        query = list(language = 'en'), body = NULL,
        request_controls = list(timeout = 30L, max_retries = 0L, retry_writes = FALSE),
        server = list(diagnostic = 'Relative server URL requires a recorded origin or explicit base URL override')
      ),
      response = list(id = 'item-1', title = 'Example item')
    )),
    result = list(id = 'item-1', title = 'Example item')
  )
)
fixture <- file.path(root, 'tests/testthat/fixtures/contracts.rds')
dir.create(dirname(fixture), recursive = TRUE)
saveRDS(contracts, fixture)
cat('\ncontracts_file: tests/testthat/fixtures/contracts.rds\n',
    file = file.path(root, 'apis/default.yml'), append = TRUE)

Keys such as get_item are public wrapper names. Inputs use their public R names. Specify types intentionally: 1L is an integer, 1 is a double, and exact expectations distinguish them. Use actual response classes (for example tibbles) when preserving an existing client. Never put credentials in fixtures.

Optional contract environment values are scoped environment variables for the test, implemented with withr::local_envvar(). Add withr to that client’s test dependencies if using this field. Keep environment values nonsecret.

3. Add the standard package test runner

Initialization declares testthat in Suggests but does not create its runner. For this new client, create tests/testthat.R:

writeLines(c(
  'library(testthat)', 'library(testedclient)', "test_check('testedclient')"
), file.path(root, 'tests/testthat.R'))

Use your own package name. Existing packages should keep their current runner and manual tests.

4. Generate and run the test

# Offline fixture only; real schemas use the required validation gate.
plan <- specmill::generate_client(validation = FALSE, root, config = 'specmill.yml', mode = 'plan')
stopifnot(!length(plan$diagnostics), length(plan$operations) == 1L)
applied <- specmill::generate_client(validation = FALSE, root, config = 'specmill.yml', mode = 'apply')
checked <- specmill::generate_client(validation = FALSE, root, config = 'specmill.yml', mode = 'check')
stopifnot(file.exists(file.path(root, 'tests/testthat/test-contract-get_item.R')))

The fixed sequence test reads the RDS fixture and replaces api_request in the client namespace with a recorder. The placeholder URL is never contacted.

testthat::test_local(root, filter = 'contract-get_item', stop_on_failure = TRUE)
#> ✔ | F W  S  OK | Context
#> 
#> ⠏ |          0 | contract-get_item                                              
#> ⠋ |          1 | contract-get_item                                              
#> ✔ |          2 | contract-get_item
#> 
#> ══ Results ═════════════════════════════════════════════════════════════════════
#> [ FAIL 0 | WARN 0 | SKIP 0 | PASS 2 ]
#> 
#> Way to go!

Use stop_on_failure = TRUE in automation. Merely exiting R successfully is not enough if a test command only prints failures. In a larger client, also run the existing bespoke tests for the helpers and hooks you changed.

5. Inspect test coverage separately from execution

inspection <- specmill::inspect_client(root)
inspection$coverage
#> $testedclient
#> $testedclient$total
#> [1] 1
#> 
#> $testedclient$implemented
#> [1] 1
#> 
#> $testedclient$contracts
#> [1] 1
stopifnot(inspection$coverage$testedclient$contracts == 1L)

implemented counts selected named exports with source definitions. contracts counts fixed contract declarations with generated test files. Neither count says tests passed. Unsupported selected operations stay in the coverage denominator; manual exports are reported separately.

generation_command(root, kind = 'tests', args = '--check') also requires fixed contracts for all selected operations. Its --dry-run mode can show gaps before you finish authoring them. The core generate_client() generates supplied contracts without requiring every selected operation to have one.

Inline YAML contracts

For a simple single-call test, use contracts instead of an RDS file:

response_fixture: {id: item-1}
contracts:
  get_item:
    inputs: {item_id: item-1, language: en}
    request:
      method: GET
      path: /items/{item_id}
      path_params: {item_id: item-1}
      query: {language: en}
      body: null
      request_controls: {timeout: 30, max_retries: 0, retry_writes: false}
      server:
        diagnostic: Relative server URL requires a recorded origin or explicit base URL override
    result: {id: item-1}

A contract can override response_fixture. Inline single-call tests use test-<operation>.R; RDS sequence tests use test-contract-<operation>.R. Prefer the latter when a manual test already uses the simple name, multiple helper calls matter, or results have R classes. Do not declare the same operation in both inline and RDS contracts.

Candidate input fixtures are not independent expectations

operation_fixtures() chooses candidate inputs in the order override -> parameter/media example -> schema example -> default -> enum -> type fixture. It checks supported constraints and stops if the chosen candidate fails them; it does not silently try a lower priority value. Examples never become public function defaults and are never evaluated as R code. Required public arguments remain required.

Automatic selection uses the singular example field. Plural examples collections require a reviewed override to select a member. Fixture creation also rejects empty query values unless the parameter declares allowEmptyValue: true, as generated wrappers do.

The default fixture policy exercises optional inputs. Explicit mode = "minimal" omits optional arguments, optional bodies, and optional object fields during fallback synthesis. Explicit overrides and declared examples remain validated. Each minimal input list records attr(inputs, "omitted_inputs"); report reduced coverage separately. Omission may activate a wrapper default, so it does not prove omission from the outgoing request. Explicit NULL, empty strings, FALSE, and zero remain supplied values.

read_operations()$fixture_diagnostics reports type/enum contradictions with source pointers and requiredness separately from parser blockers. Optional contradictions remain visible in the default audit even when omission permits a request. Fixture construction and localhost transport checks do not establish live service acceptance.

operations <- specmill::read_operations(schema)$operations
candidate_inputs <- specmill::operation_fixtures(operations)
candidate_inputs$get_item
#> $item_id
#> [1] "example"
#> 
#> $language
#> [1] "en"

Review these values before using them against a service. They do not establish expected helper calls or correct upstream results. check_requests() is a small alternative for checking one zero-argument call against a capture function and independent result; see its reference example.

Add the verification layer your change needs

Layer What to verify
Generation check Committed output matches schema, policy, callbacks, and fixtures
Fixed helper contract Successful wrapper result plus exact ordered helper calls
Hook tests Validation, parameter changes, skip behavior, and post-processing
Local transport test Final method, encoded URL, query/body placement, headers, media, and error decoding
Bounded live check Current endpoint/authentication behavior using reviewed nonsecret input

Use a local HTTP server or existing HTTP fixtures to test transport. Do not re-record production traffic just to update generated tests. A live failure can come from input, authentication, schema drift, generation, runtime code, or an outage. Diagnose it before editing generated source or relaxing expectations.