Skip to content

Migration Guide

Bryan edited this page Jan 30, 2026 · 3 revisions

Migrating from 0.7 to 1.0

SpecForge 1.0 is a complete architectural redesign. This guide walks you through migrating your existing tests to the new step-based workflow system.

Overview of Changes

What changed:

  • File structure: Named specs -> Array of sequential steps
  • Variable syntax: variables.xxx -> {{ xxx }}
  • Storage: store_as: -> store: attribute
  • Directory: specs/ -> blueprints/
  • Global config: Moved from YAML to forge_helper.rb
  • Validation: New shape: and schema: modes

What stayed the same:

  • Faker integration (faker.xxx)
  • FactoryBot integration (factories.xxx)
  • RSpec matchers (kind_of., matcher., be.)
  • OpenAPI generation workflow
  • Core CLI commands

Directory Structure

Rename your specs directory:

mv spec_forge/specs spec_forge/blueprints

Update any scripts or CI configs that reference the old path.

CLI Commands

0.7 Command 1.0 Command
spec_forge new spec users spec_forge new blueprint users
spec_forge run users:get_user spec_forge run blueprints/users.yml
spec_forge run users:get_user:'GET /users' Use --tags for filtering

File Structure

This is the biggest conceptual change. In 0.7, files contained a hash of named specs. In 1.0, files contain an array of sequential steps.

Before (0.7)

# spec_forge/specs/users.yml
global:
  variables:
    admin_role: "admin"

create_user:
  path: /users
  method: POST
  variables:
    email: faker.internet.email
  body:
    email: variables.email
    role: global.variables.admin_role
  store_as: new_user
  expectations:
  - expect:
      status: 201
      json:
        id: kind_of.integer
        email: variables.email

get_user:
  path: /users/{id}
  query:
    id: store.new_user.body.id
  expectations:
  - expect:
      status: 200
      json:
        id: store.new_user.body.id

After (1.0)

# spec_forge/blueprints/users.yml
- name: "Create user"
  request:
    url: /users
    http_verb: POST
    json:
      email: "{{ faker.internet.email }}"
      role: "{{ admin_role }}"
  expect:
  - status: 201
    json:
      shape:
        id: integer
        email: string
  store:
    new_user_id: "{{ response.body.id }}"
    new_user_email: "{{ response.body.email }}"

- name: "Get user"
  request:
    url: "/users/{{ new_user_id }}"
  expect:
  - status: 200
    json:
      content:
        id: "{{ new_user_id }}"

Key differences:

  • Root is an array (-), not a hash of named specs
  • Each step has a name: attribute
  • Request config is nested under request:
  • Variables use {{ }} syntax
  • store: captures specific values, not entire responses
  • expect: is a direct array (not expectations: with expect: inside)

Request Configuration

Attribute Names

0.7 1.0 Primary 1.0 Alias
path: url: path: works
method: http_verb: method: works
body: json: body: for non-JSON

Before (0.7)

create_user:
  path: /users
  method: POST
  body:
    name: "Test User"

After (1.0)

- name: "Create user"
  request:
    url: /users
    http_verb: POST
    json:
      name: "Test User"

Note: The aliases (path:, method:) still work, but examples and docs use the primary names.

Variables

Local Variables

Before (0.7):

create_user:
  variables:
    email: faker.internet.email
  body:
    email: variables.email

After (1.0):

- store:
    email: "{{ faker.internet.email }}"

- name: "Create user"
  request:
    json:
      email: "{{ email }}"

Or inline with the request:

- name: "Create user"
  request:
    json:
      email: "{{ faker.internet.email }}"

Global Variables

Global variables moved from YAML to forge_helper.rb:

Before (0.7):

# In your spec file
global:
  variables:
    admin_role: "admin"
    api_version: "v1"

create_user:
  body:
    role: global.variables.admin_role

After (1.0):

# spec_forge/forge_helper.rb
SpecForge.configure do |config|
  config.base_url = "http://localhost:3000"
  
  config.global_variables = {
    admin_role: "admin",
    api_version: "v1"
  }
end
# In your blueprint
- name: "Create user"
  request:
    json:
      role: "{{ admin_role }}"

Why this changed: Global variables are now truly global - available across all blueprints, not just one file.

Storing Response Data

The store_as: directive is replaced by the store: attribute with explicit field extraction.

Before (0.7)

create_user:
  path: /users
  method: POST
  body:
    name: "Test User"
  store_as: new_user
  expectations:
  - expect:
      status: 201

get_user:
  path: /users/{id}
  query:
    id: store.new_user.body.id

After (1.0)

- name: "Create user"
  request:
    url: /users
    http_verb: POST
    json:
      name: "Test User"
  expect:
  - status: 201
  store:
    user_id: "{{ response.body.id }}"

- name: "Get user"
  request:
    url: "/users/{{ user_id }}"

Key differences:

  • store: explicitly names what to capture
  • Reference stored values directly ({{ user_id }}) not via store.xxx.body.id
  • response.body and response.headers available in store expressions

Accessing Response Data

0.7 1.0
store.new_user.body.id {{ user_id }} (after storing)
store.new_user.headers.Location {{ location }} (after storing)
store.new_user.status Not typically needed

To store multiple values:

store:
  user_id: "{{ response.body.id }}"
  user_email: "{{ response.body.email }}"
  location: "{{ response.headers.location }}"

Expectations

The structure simplified: expectations: with nested expect: became just expect: as an array.

Before (0.7)

get_user:
  path: /users/1
  expectations:
  - expect:
      status: 200
      json:
        id: kind_of.integer
        name: kind_of.string

After (1.0)

- name: "Get user"
  request:
    url: /users/1
  expect:
  - status: 200
    json:
      shape:
        id: integer
        name: string

JSON Validation Modes

1.0 introduces explicit validation modes:

shape: - Type validation (most common)

json:
  shape:
    id: integer
    name: string
    email: string
    tags: [string]        # Array of strings
    metadata: ?hash       # Nullable

content: - Exact value matching

json:
  content:
    status: "active"
    role: "{{ admin_role }}"

schema: - Advanced control (rare)

json:
  schema:
    type: array
    structure:
    - integer
    - string

Important: shape: and schema: only accept type names - they don't support {{ }} interpolation or matchers. If you need matcher-based validation, use content::

# For type checking - use shape with type names
json:
  shape:
    id: integer
    name: string

# For matcher-based validation - use content
json:
  content:
    id: "{{ kind_of.integer }}"
    tags:
      matcher.include: "featured"

Optional and Nullable Fields (New in 1.0)

1.0 introduces type flags for handling fields that may be missing or null -- something 0.7 had no built-in way to express.

Flag Meaning Example
? Nullable (value can be null) ?string
* Optional (key can be missing) *string

Flags combine in any order:

json:
  shape:
    id: integer           # Required, non-null
    deleted_at: ?string   # Required, can be null
    nickname: *string     # Optional, non-null if present
    bio: *?string         # Optional, can be null

Behavior matrix:

Syntax Key Missing Value Null Value Present
string [x] fail [x] fail [ok] pass
?string [x] fail [ok] pass [ok] pass
*string [ok] pass [x] fail [ok] pass
*?string [ok] pass [ok] pass [ok] pass

See Validating Responses for complete coverage.

Headers

Global Headers Removed

0.7 supported global headers in configuration. 1.0 removed this in favor of explicit inheritance.

Before (0.7):

# forge_helper.rb
SpecForge.configure do |config|
  config.headers = {
    "Authorization" => "Bearer #{ENV['API_TOKEN']}"
  }
end

After (1.0):

# Use nesting with shared: wrapper
- name: "Authenticated requests"
  shared:
    request:
      headers:
        Authorization: "Bearer {{ api_token }}"
  steps:
  - name: "Get profile"
    request:
      url: /me

  - name: "Update profile"
    request:
      url: /me
      http_verb: PUT

Why this changed: Explicit headers in blueprints make tests self-documenting. You can see exactly what headers are sent by reading the YAML.

Nesting and Inheritance

Use steps: with the shared: wrapper to group steps and share configuration.

Before (0.7)

There wasn't a direct equivalent - you'd repeat headers on each spec.

After (1.0)

- name: "User CRUD"
  shared:
    request:
      headers:
        Authorization: "Bearer {{ token }}"
  steps:
  - name: "Create"
    request:
      url: /users
      http_verb: POST
      json:
        name: "Test"
    store:
      user_id: "{{ response.body.id }}"

  - name: "Read"
    request:
      url: "/users/{{ user_id }}"

  - name: "Delete"
    request:
      url: "/users/{{ user_id }}"
      http_verb: DELETE

All nested steps inherit the Authorization header.

Note: shared: also works for hook: configuration, not just request:. See Callbacks for details.

Callbacks and Hooks

Registration (unchanged)

# forge_helper.rb
SpecForge.configure do |config|
  config.register_callback(:seed_db) do |context|
    # Setup code
  end
end

Usage in YAML

Before (0.7):

global:
  callbacks:
  - before_file: seed_db
    after_file: cleanup_db

After (1.0):

- hook:
    before_blueprint: seed_db
    after_blueprint: cleanup_db

Hook Name Changes

0.7 1.0
before_file before_blueprint
after_file after_blueprint
before before_step
after after_step

New in 1.0:

  • before_forge / after_forge - Run once for entire test suite

Tags (New in 1.0)

Tags let you filter which steps run:

- name: "Health check"
  tags: [critical, public]
  request:
    url: /health

- name: "Admin dashboard access"
  tags: [admin, authenticated]
  request:
    url: /admin/dashboard
spec_forge run --tags critical
spec_forge run --skip-tags admin
spec_forge run --tags public,authenticated --skip-tags admin

Complete Migration Example

Before (0.7)

# spec_forge/specs/posts.yml
global:
  variables:
    default_status: "draft"
  callbacks:
  - before_file: seed_database

create_post:
  path: /posts
  method: POST
  variables:
    title: faker.lorem.sentence
  body:
    title: variables.title
    status: global.variables.default_status
  store_as: new_post
  expectations:
  - expect:
      status: 201
      json:
        id: kind_of.integer
        title: variables.title

get_post:
  path: /posts/{id}
  query:
    id: store.new_post.body.id
  expectations:
  - expect:
      status: 200
      json:
        id: store.new_post.body.id
        status: global.variables.default_status

update_post:
  path: /posts/{id}
  method: PUT
  query:
    id: store.new_post.body.id
  body:
    status: "published"
  expectations:
  - expect:
      status: 200
      json:
        status: "published"

After (1.0)

# spec_forge/blueprints/posts.yml
- hook:
    before_blueprint: seed_database

- name: "Create post"
  request:
    url: /posts
    http_verb: POST
    json:
      title: "{{ faker.lorem.sentence }}"
      status: "{{ default_status }}"
  expect:
  - status: 201
    json:
      shape:
        id: integer
        title: string
  store:
    post_id: "{{ response.body.id }}"

- name: "Get post"
  request:
    url: "/posts/{{ post_id }}"
  expect:
  - status: 200
    json:
      content:
        id: "{{ post_id }}"
        status: "{{ default_status }}"

- name: "Update post"
  request:
    url: "/posts/{{ post_id }}"
    http_verb: PUT
    json:
      status: "published"
  expect:
  - status: 200
    json:
      content:
        status: "published"

With the global variable in forge_helper.rb:

SpecForge.configure do |config|
  config.base_url = "http://localhost:3000"
  config.global_variables = {
    default_status: "draft"
  }
end

Keep in Mind

1. Forgetting request: wrapper

# Wrong
- name: "Get user"
  url: /users/1

# Right
- name: "Get user"
  request:
    url: /users/1

2. Missing {{ }} in string interpolation

# Wrong
email: faker.internet.email

# Right
email: "{{ faker.internet.email }}"

3. Using old store syntax

# Wrong
url: "/users/{{ store.new_user.body.id }}"

# Right (after storing as user_id)
url: "/users/{{ user_id }}"

4. Combining actions with steps:

You cannot combine action attributes (request, expect, call, debug, store) with steps:. A step either executes an action OR organizes substeps, not both.

# Wrong - can't mix action with steps
- name: "Auth requests"
  request:                              # This is an action
    headers:
      Authorization: "Bearer {{ token }}"
  steps:                                # Can't have both!
  - name: "Get profile"
    request:
      url: /me

# Right - use shared: wrapper for inheritance
- name: "Auth requests"
  shared:
    request:
      headers:
        Authorization: "Bearer {{ token }}"
  steps:
  - name: "Get profile"
    request:
      url: /me  # Inherits auth header

Why this matters: The shared: wrapper makes inheritance explicit — anything in shared: cascades down to nested steps. Without it, there's ambiguity about what applies where.

Getting Help

Clone this wiki locally