Testing and Assertions
Testing and assertions are the quality gates of Probe workflows. They validate that your actions produce expected results and ensure system reliability. This guide explores test expressions, assertion patterns, and strategies for building robust validation into your workflows.
Testing Fundamentals
Every step in Probe can include a test condition that validates the action’s result. Tests are boolean expressions that determine whether a step succeeded or failed.
Basic Test Structure
A test is one expression on the step, and the step passes when it evaluates to true.
- name: API Health Check
uses: http
with:
method: GET
url: "{{vars.API_URL}}/health"
test: res.code == 200The test expression evaluates the response (res) and returns true for success or false for failure.
Test Expression Context
Test expressions have access to comprehensive response data:
# HTTP Response Testing Context
test: |
res.code == 200 && # HTTP status code
(rt.sec * 1000) < 1000 && # Response time in milliseconds
res.body_size > 0 && # Response body size in bytes
res.headers["Content-Type"] == "application/json" && # Response headers
res.body.status == "healthy" && # Parsed JSON response
res.body contains "success" # Response body as textHTTP Response Testing
An HTTP response offers four things worth asserting on: the status code, how long it took, how much came back, and the headers.
Status Code Validation
res.code can be compared to a single value, to a range, or to a set of codes that are all acceptable.
# Exact status code
test: res.code == 200
# Status code ranges
test: res.code >= 200 && res.code < 300
# Multiple acceptable codes
test: res.status in [200, 201, 202]
# Client vs server errors
test: res.code < 400 # Success or redirect
test: res.code >= 400 && res.code < 500 # Client error
test: res.code >= 500 # Server errorResponse Time Testing
rt.sec holds the round trip in seconds, so a time budget is written as an ordinary comparison.
# Performance validation
test: (rt.sec * 1000) < 1000 # Must respond within 1 second
test: (rt.sec * 1000) >= 100 && (rt.sec * 1000) <= 500 # Response time range
test: (rt.sec * 1000) < {{vars.MAX_RESPONSE_TIME || 2000}} # Configurable threshold
# Performance categories
test: |
res.code == 200 && (
(rt.sec * 1000) < 200 ? "excellent" :
(rt.sec * 1000) < 500 ? "good" :
(rt.sec * 1000) < 1000 ? "acceptable" : "poor"
) != "poor"Response Size Validation
res.body_size catches an empty body and a response that came back far larger than expected.
# Content presence
test: res.body_size > 0 # Has content
test: res.body_size > 100 # Minimum content size
test: res.body_size < 1048576 # Maximum 1MB response
# Size-based validation
test: |
res.code == 200 &&
res.body_size > 50 && # Not empty error message
res.body_size < 100000 # Not unexpectedly largeHeader Validation
res.headers is indexed by header name, which covers content type and the security headers a response is expected to carry.
# Content type checking
test: res.headers["Content-Type"] == "application/json"
test: res.headers["Content-Type"] startsWith "text/"
test: res.headers["Content-Type"] contains "charset=utf-8"
# Security headers
test: |
"X-Frame-Options" in res.headers &&
"X-Content-Type-Options" in res.headers &&
res.headers["X-Frame-Options"] == "DENY"
# Cache control
test: res.headers["Cache-Control"] contains "no-cache"
# Rate limiting
test: res.headers["X-Rate-Limit-Remaining"] > "10"
# Custom headers
test: |
"X-Request-Id" in res.headers &&
len(res.headers["X-Request-Id"]) == 36 # UUID formatJSON Response Testing
When the body is JSON, Probe parses it, so a test can address fields directly instead of matching text.
Basic JSON Validation
Once the body is parsed, a test walks into it by field name.
# JSON structure validation
test: |
res.code == 200 &&
res.body != null &&
res.body.status == "success" &&
res.body.data != null
# Required fields presence
test: |
"id" in res.body &&
"name" in res.body &&
"email" in res.body &&
"created_at" in res.bodyData Type Validation
typeof asserts that a field came back as the type the client expects, not merely that it is present.
# Type checking
test: |
typeof(res.body.id) == "number" &&
typeof(res.body.name) == "string" &&
typeof(res.body.active) == "boolean" &&
typeof(res.body.tags) == "array" &&
typeof(res.body.metadata) == "object"
# Value constraints
test: |
res.body.id > 0 &&
len(res.body.name) >= 2 &&
res.body.score >= 0 && res.body.score <= 100Array and Collection Testing
len gives the number of elements, and elements are addressed by index to check their contents.
# Array validation
test: |
res.body.users != null &&
len(res.body.users) > 0 &&
len(res.body.users) <= 100
# Array content validation
test: |
all(res.body.users, #.id != null &&
#.email != null)
# Specific element checks
test: |
any(res.body.users, #.role == "admin") &&
len(filter(res.body.users, #.active == true)) > 0
# Array uniqueness
test: |
len(res.body.user_ids) == len(uniq(res.body.user_ids))Nested Data Validation
Reaching a nested field means every level above it has to exist, so each one is checked on the way down.
# Deep object validation
test: |
res.body.user != null &&
res.body.user.profile != null &&
res.body.user.profile.preferences != null &&
res.body.user.profile.preferences.notifications == true
# Complex nested structures
test: |
all(res.body.data.orders, #.id != null &&
len(#.items) > 0 &&
#all(.items, #.product_id != null &&
#.quantity > 0 &&
#.price > 0) &&
#.total == sum(map(#.items, #.quantity * #.price)))Text Response Testing
When the body is not JSON, it is a string. Tests on it work by substring, by regular expression, or by length.
Pattern Matching
contains, startsWith and endsWith work on the body as a string, and lower makes the comparison case-insensitive.
# Simple text matching
test: res.body contains "success"
test: res.body startsWith "<!DOCTYPE html>"
test: res.body endsWith "</html>"
# Case-insensitive matching
test: lower(res.body) contains "error"
# Multiple patterns
test: |
res.body contains "status" &&
res.body contains "healthy" &&
!res.body contains "error"Regular Expression Testing
matches applies a regular expression, which is what to reach for when the value has a format rather than a fixed text.
# Email validation in response
test: res.body matches "user-\\d+@example\\.com"
# URL pattern validation
test: res.body matches "https://[a-zA-Z0-9.-]+/api/v\\d+/"
# Data format validation
test: |
res.body matches "\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}Z" # ISO timestamp
# Extract and validate data
test: |
res.body matches "Version: v\\d+\\.\\d+\\.\\d+" &&
res.body.extract("v(\\d+)\\.(\\d+)\\.(\\d+)")[1] >= "2" # Major version >= 2Content Length and Quality
len on the body bounds how much came back, and the same expression can assert that the content is not an error page.
# Content length validation
test: |
len(res.body) > 100 &&
len(res.body) < 10000
# Content quality checks
test: |
len(split(res.body, "\\n")) > 5 && # Multi-line content
!res.body contains "Lorem ipsum" && # Not placeholder text
len(split(res.body, " ")) > 20 # Substantial contentAdvanced Testing Patterns
Some checks cannot be written against a single response: they depend on what the run is doing, on an earlier step, or on a rule the application is supposed to enforce.
Conditional Testing
A test is an expression, so it can pick its own threshold from the environment it is running against.
# Environment-specific tests
test: |
res.code == 200 &&
(vars.NODE_ENV == "development" ?
(rt.sec * 1000) < 5000 : # More lenient for dev
(rt.sec * 1000) < 1000 # Strict for production
)
# Feature flag testing
test: |
res.code == 200 &&
(res.body.features.beta_enabled == true ?
res.body.beta_data != null : # Beta features should have data
res.body.beta_data == null # Beta features should be absent
)Cross-Step Validation
A step can assert against an earlier step’s outputs, which is how two endpoints are checked for agreement.
jobs:
- id: data-consistency-test
name: data-consistency-test
steps:
- name: Get User Count
id: user-count
uses: http
with:
method: GET
url: "{{vars.API_URL}}/users/count"
test: res.code == 200
outputs:
total_users: res.body.count
- name: Get User List
id: user-list
uses: http
with:
method: GET
url: "{{vars.API_URL}}/users"
test: |
res.code == 200 &&
len(res.body.users) == outputs['user-count'].total_users # Consistency check
outputs:
user_list: res.body.users
- name: Validate User Data Integrity
uses: http
with:
method: GET
url: "{{vars.API_URL}}/users/{{outputs['user-list'].user_list[0].id}}"
test: |
res.code == 200 &&
res.body.user.id == outputs['user-list'].user_list[0].id &&
res.body.user.email == outputs['user-list'].user_list[0].emailBusiness Logic Testing
Some rules hold between fields rather than in any one of them, such as a total that has to match its line items.
- name: E-commerce Business Logic Test
uses: http
with:
method: GET
url: "{{vars.API_URL}}/orders/{{vars.TEST_ORDER_ID}}"
test: |
res.code == 200 &&
res.body.order != null &&
# Order total equals sum of line items
res.body.order.total ==
sum(map(res.body.order.line_items, #.quantity * #.price)) &&
# Tax calculation is correct (assuming 8% tax rate)
res.body.order.tax_amount ==
round(Math) / 100 &&
# Shipping is applied correctly
(res.body.order.subtotal >= 100 ?
res.body.order.shipping_cost == 0 : # Free shipping over $100
res.body.order.shipping_cost == 9.99 # Standard shipping
) &&
# Final total calculation
res.body.order.total ==
res.body.order.subtotal + res.body.order.tax_amount + res.body.order.shipping_costError Testing and Negative Cases
A workflow that only exercises the successful path leaves the error handling untested. The tests below assert that bad input is rejected the way it should be.
Expected Error Scenarios
Here the failing response is the expected one, so the test asserts on the error code and the message that comes with it.
- name: Test Invalid Authentication
uses: http
with:
method: GET
url: "{{vars.API_URL}}/protected"
headers:
Authorization: "Bearer invalid-token"
test: |
res.code == 401 &&
res.body.error == "invalid_token" &&
res.body.message contains "authentication"
- name: Test Rate Limiting
uses: http
with:
method: GET
url: "{{vars.API_URL}}/rate-limited-endpoint"
test: |
res.status in [200, 429] && # Either success or rate limited
(res.code == 429 ?
"Retry-After" in res.headers &&
res.body.error == "rate_limit_exceeded" :
res.body.status == "success"
)
- name: Test Malformed Request
uses: http
with:
url: "{{vars.API_URL}}/users"
method: POST
body: '{"invalid": json}' # Intentionally malformed
test: |
res.code == 400 &&
res.body.error contains "json" &&
res.body.details != nullBoundary Testing
Input at the edge of what is allowed is where validation tends to break, so it is sent deliberately.
- name: Test Input Boundaries
uses: http
with:
url: "{{vars.API_URL}}/users"
method: POST
body: |
{
"name": "{{random_str(255)}}", # Maximum length
"age": 150, # Upper boundary
"score": 0 # Lower boundary
}
test: |
res.status in [201, 400] && # Either created or validation error
(res.code == 400 ?
res.body.validation_errors != null :
res.body.user.id != null
)Test Organization Patterns
As a workflow grows, the question becomes what to check where. The patterns below layer the checks and group them into suites.
Layered Testing Strategy
Splitting the checks into jobs lets the cheap ones run first and stop the run before the expensive ones start.
jobs:
- id: smoke-tests
name: Smoke Tests
steps:
- name: Basic Connectivity
uses: http
with:
method: GET
url: "{{vars.API_URL}}/ping"
test: res.code == 200
- id: functional-tests
name: Functional Tests
needs: [smoke-tests]
steps:
- name: User Management
uses: http
with:
method: GET
url: "{{vars.API_URL}}/users"
test: |
res.code == 200 &&
res.body.users != null &&
res.body.pagination != null
- id: integration-tests
name: Integration Tests
needs: [functional-tests]
steps:
- name: Cross-Service Integration
uses: http
with:
method: GET
url: "{{vars.API_URL}}/integration/full-flow"
test: |
res.code == 200 &&
res.body.all_services_connected == true &&
res.body.data_consistency_check == trueComprehensive Test Suites
A full suite groups the cases by what they cover and carries the credentials obtained at the start through the rest of the run.
jobs:
- id: api-test-suite
name: Comprehensive API Test Suite
steps:
# Authentication Tests
- name: Valid Login
id: login
uses: http
with:
url: "{{vars.API_URL}}/auth/login"
method: POST
body: |
{
"username": "{{vars.TEST_USERNAME}}",
"password": "{{vars.TEST_PASSWORD}}"
}
test: |
res.code == 200 &&
res.body.access_token != null &&
res.body.refresh_token != null &&
res.body.expires_in > 0
outputs:
access_token: res.body.access_token
- name: Invalid Login
uses: http
with:
url: "{{vars.API_URL}}/auth/login"
method: POST
body: |
{
"username": "invalid",
"password": "wrong"
}
test: |
res.code == 401 &&
res.body.error == "invalid_credentials"
# CRUD Operations Tests
- name: Create User
id: create-user
uses: http
with:
url: "{{vars.API_URL}}/users"
method: POST
headers:
Authorization: "Bearer {{outputs.login.access_token}}"
body: |
{
"name": "Test User {{random_str(6)}}",
"email": "test{{random_str(8)}}@example.com",
"role": "user"
}
test: |
res.code == 201 &&
res.body.user.id != null &&
res.body.user.name != null &&
res.body.user.email != null
outputs:
user_id: res.body.user.id
user_email: res.body.user.email
- name: Read User
uses: http
with:
method: GET
url: "{{vars.API_URL}}/users/{{outputs['create-user'].user_id}}"
headers:
Authorization: "Bearer {{outputs.login.access_token}}"
test: |
res.code == 200 &&
res.body.user.id == outputs['create-user'].user_id &&
res.body.user.email == "{{outputs['create-user'].user_email}}"
- name: Update User
uses: http
with:
url: "{{vars.API_URL}}/users/{{outputs['create-user'].user_id}}"
method: PUT
headers:
Authorization: "Bearer {{outputs.login.access_token}}"
body: |
{
"name": "Updated Test User"
}
test: |
res.code == 200 &&
res.body.user.name == "Updated Test User"
- name: Delete User
uses: http
with:
url: "{{vars.API_URL}}/users/{{outputs['create-user'].user_id}}"
method: DELETE
headers:
Authorization: "Bearer {{outputs.login.access_token}}"
test: res.code == 204
- name: Verify Deletion
uses: http
with:
method: GET
url: "{{vars.API_URL}}/users/{{outputs['create-user'].user_id}}"
headers:
Authorization: "Bearer {{outputs.login.access_token}}"
test: res.code == 404Performance Testing
res.time makes response time an ordinary assertion, so a workflow can hold a service to a time budget as well as a correctness one.
Response Time Benchmarks
A benchmark pairs the correctness assertion with the time it is allowed to take.
- name: Performance Benchmark Test
uses: http
with:
method: GET
url: "{{vars.API_URL}}/performance-test"
test: |
res.code == 200 &&
# Tiered performance expectations
(vars.NODE_ENV == "production" ?
(rt.sec * 1000) < 500 : # Production: < 500ms
(rt.sec * 1000) < 2000 # Non-production: < 2s
) &&
# Additional performance metrics
res.body.query_time < 100 && # Database query time
res.body.render_time < 50 # Template render time
outputs:
response_time: (rt.sec * 1000)
query_time: res.body.query_time
render_time: res.body.render_timeLoad Testing Validation
When load is generated elsewhere, Probe reads the results back and asserts on them.
- name: Load Test Results Validation
uses: http
with:
method: GET
url: "{{vars.LOAD_TEST_URL}}/results"
test: |
res.code == 200 &&
res.body.test_completed == true &&
# Success rate requirements
res.body.success_rate >= 0.95 &&
# Performance percentiles
res.body.percentiles.p50 < 1000 &&
res.body.percentiles.p95 < 2000 &&
res.body.percentiles.p99 < 5000 &&
# Error rate limits
res.body.error_rate < 0.05 &&
# No critical errors
res.body.critical_errors == 0Security Testing
Two things are worth asserting on every API: that it refuses a request without valid credentials, and that it rejects input it should not accept.
Authentication and Authorization
A request without credentials has to be refused, and the test asserts on that refusal.
- name: Test Unauthorized Access
uses: http
with:
method: GET
url: "{{vars.API_URL}}/admin/users"
test: |
res.code == 401 &&
res.body.error == "authentication_required"
- name: Test Insufficient Permissions
uses: http
with:
method: GET
url: "{{vars.API_URL}}/admin/users"
headers:
Authorization: "Bearer {{vars.USER_TOKEN}}" # Regular user token
test: |
res.code == 403 &&
res.body.error == "insufficient_permissions"
- name: Test Token Expiration
uses: http
with:
method: GET
url: "{{vars.API_URL}}/protected"
headers:
Authorization: "Bearer {{vars.EXPIRED_TOKEN}}"
test: |
res.code == 401 &&
res.body.error == "token_expired"Input Validation Security
Hostile input should be rejected or neutralised, and either outcome is acceptable as long as it is not executed.
- name: Test SQL Injection Protection
uses: http
with:
method: GET
url: "{{vars.API_URL}}/users?search='; DROP TABLE users; --"
test: |
res.status in [200, 400] && # Either filtered or rejected
!res.body contains "sql" && # No SQL error messages
!res.body contains "syntax" &&
res.body.error != "internal_server_error" # Should not cause server error
- name: Test XSS Protection
uses: http
with:
url: "{{vars.API_URL}}/comments"
method: POST
body: |
{
"content": "<script>alert('xss')</script>"
}
test: |
res.status in [201, 400] &&
(res.code == 201 ?
!res.body.comment.content contains "<script>" : # Should be sanitized
res.body.validation_errors != null # Or rejected
)Test Documentation and Reporting
A test that fails is only useful if the output says what was expected. Naming and aggregation do that work.
Self-Documenting Tests
The step name and the assertions together should say what the case was, so a failure needs no outside explanation.
- name: User Registration Flow Test
uses: http
with:
url: "{{vars.API_URL}}/auth/register"
method: POST
body: |
{
"email": "{{random_str(8)}}@example.com",
"password": "TestPass123!",
"confirm_password": "TestPass123!"
}
# Comprehensive test with clear validation points
test: |
res.code == 201 && # 1. Successful creation
res.body.user.id != null && # 2. User ID assigned
res.body.user.email != null && # 3. Email stored
res.body.user.password == null && # 4. Password not returned
res.body.user.created_at != null && # 5. Timestamp recorded
res.body.user.email_verified == false && # 6. Email unverified initially
res.body.verification_email_sent == true && # 7. Verification triggered
"Location" in res.headers && # 8. Location header present
res.headers["Location"] contains "/users/" # 9. Correct redirect path
outputs:
user_id: res.body.user.id
user_email: res.body.user.email
test_summary: |
Registration test completed:
- User ID: {{res.body.user.id}}
- Email: {{res.body.user.email}}
- Verification: {{res.body.verification_email_sent ? "Sent" : "Failed"}}
- Response time: {{rt.duration}}Test Result Aggregation
A final job that depends on the others can read their status and print one summary.
jobs:
- id: test-summary
name: Test Results Summary
needs: [smoke-tests, functional-tests, security-tests]
steps:
- name: Generate Test Report
uses: hello
echo: |
Test Execution Summary
=====================
Overall Result: {{
}}
Execution Time: {{unixtime()}}
Test Environment: {{vars.NODE_ENV || "development"}}Best Practices
The points below keep a growing test suite readable and keep its failures informative.
1. Clear Test Intentions
A test should say what it expects. Asserting only that something is non-null passes for reasons that have nothing to do with the case.
# Good: Specific, testable conditions
test: |
res.code == 200 &&
len(res.body.users) >= 1 &&
res.body.users[0].id != null
# Avoid: Vague or incomplete tests
test: res.code == 200 # What about response content?2. Comprehensive Error Coverage
Every case worth testing has a failing counterpart, and both belong in the suite.
# Good: Test both success and failure paths
- name: Valid Request Test
test: res.code == 200 && res.body.success == true
- name: Invalid Request Test
test: res.code == 400 && res.body.error != null3. Performance-Aware Testing
Adding a time bound to an existing test costs nothing and catches a regression that correctness checks miss.
# Good: Include performance validation
test: |
res.code == 200 &&
(rt.sec * 1000) < 1000 &&
res.body.data != null
# Good: Environment-specific performance thresholds
test: |
res.code == 200 &&
(rt.sec * 1000) < {{vars.MAX_RESPONSE_TIME || 2000}}4. Maintainable Test Expressions
A long condition is easier to read split across lines, one assertion per line.
# Good: Readable, well-structured tests
test: |
res.code == 200 &&
res.body.user != null &&
res.body.user.id > 0 &&
res.body.user.email contains "@"
# Avoid: Complex, hard-to-read tests
test: res.code == 200 && res.body.user != null && res.body.user.id > 0 && res.body.user.email contains "@" && res.body.user.active == true && (rt.sec * 1000) < 1000What’s Next?
Now that you understand testing and assertions, explore:
- Error Handling - Learn to handle failures gracefully
- Execution Model - Understand workflow execution flow
- How-tos - See practical testing patterns in action
Testing and assertions are your quality gates. Master these concepts to build reliable, robust automation that catches issues before they impact your systems.