The brief named the deliverables. This lesson is the implementation walkthrough for the components most learners find tricky: wiring global auth through karate-config.js, building the login and create-user reusable features, writing a complete feature file with real schema validation, and setting up the parallel runner and GitHub Actions workflow. Read it as a worked reference, then return to your own project and build the parts you didn't know how to start.
Step 1 — pom.xml and project skeleton
<project>
<modelVersion>4.0.0</modelVersion>
<groupId>com.teamhub</groupId>
<artifactId>teamhub-karate</artifactId>
<version>1.0-SNAPSHOT</version>
<packaging>jar</packaging>
<properties>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<karate.version>1.4.1</karate.version>
</properties>
<dependencies>
<dependency>
<groupId>com.intuit.karate</groupId>
<artifactId>karate-junit5</artifactId>
<version>${karate.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<testResources>
<testResource>
<directory>src/test/java</directory>
<excludes><exclude>**/*.java</exclude></excludes>
</testResource>
</testResources>
<plugins>
<plugin>
<groupId>net.masterthought</groupId>
<artifactId>maven-cucumber-reporting</artifactId>
<version>5.8.1</version>
<executions>
<execution>
<id>generate-reports</id>
<phase>verify</phase>
<goals><goal>generate</goal></goals>
<configuration>
<projectName>TeamHub API Tests</projectName>
<outputDirectory>${project.build.directory}/cucumber-html-reports</outputDirectory>
<jsonFiles>
<param>${project.build.directory}/karate-reports/*.json</param>
</jsonFiles>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>One dependency, one plugin. maven.compiler.source and target at 21 — use whatever JDK you have, minimum 11.
Step 2 — karate-config.js with global auth
function fn() {
var env = karate.env || 'dev';
var config = {
baseUrl: 'https://api.dev.teamhub.io'
};
if (env == 'staging') {
config.baseUrl = java.lang.System.getenv('API_BASE_URL') || 'https://api.staging.teamhub.io';
}
// Login once per suite — token shared across all features
var loginResult = karate.callSingle('classpath:common/login.feature', {
email: java.lang.System.getenv('ADMIN_EMAIL') || 'admin@teamhub.io',
password: java.lang.System.getenv('ADMIN_PASSWORD') || 'AdminPass1'
});
karate.configure('headers', {
Authorization: 'Bearer ' + loginResult.authToken,
Accept: 'application/json',
'Content-Type': 'application/json'
});
return config;
}karate.callSingle() runs login.feature once for the entire Maven run, regardless of thread count. karate.configure('headers', ...) sets the auth header on every subsequent request in every feature file. No Background auth blocks needed anywhere.
Step 3 — common/login.feature
Feature: Login Helper
Scenario:
Given url baseUrl
And path 'auth', 'login'
And request { email: '#(email)', password: '#(password)' }
When method post
Then status 200
* def authToken = response.tokenThe authToken defined here becomes a property on the object returned by call or callSingle. In karate-config.js, loginResult.authToken is this value.
Step 4 — common/create-user.feature
Feature: Create User Helper
Scenario:
* def timestamp = java.lang.System.currentTimeMillis()
* def email = name + '-' + timestamp + '@test.teamhub.io'
Given url baseUrl
And path 'users'
And request { name: '#(name)', email: '#(email)', role: '#(role)' }
When method post
Then status 201
* def userId = response.id
* def userEmail = emailThe called feature generates a unique email from the name argument — no collision risk across parallel runs. Both userId and userEmail are returned to the caller.
Step 5 — schemas/user-schema.json
{
"id": "#uuid",
"name": "#string",
"email": "#regex .+@.+\\..+",
"role": "#? _ == 'admin' || _ == 'manager' || _ == 'member'",
"status": "#? _ == 'active' || _ == 'invited' || _ == 'disabled'",
"createdAt": "#string",
"updatedAt": "#string",
"teamId": "##string"
}#uuid validates a UUID format. #? validates enum membership. ##string marks teamId as optional — a user without a team is valid.
Step 6 — users/users.feature
Feature: Users API
Background:
* url baseUrl
* def userSchema = read('classpath:schemas/user-schema.json')
Scenario: Create a user and validate the response schema
* def newUser = call read('classpath:common/create-user.feature') { name: 'Alice', role: 'member' }
Given path 'users', newUser.userId
When method get
Then status 200
And match response == userSchema
And match response.id == newUser.userId
Scenario: Update a user's role
* def newUser = call read('classpath:common/create-user.feature') { name: 'Bob', role: 'member' }
Given path 'users', newUser.userId
And request { role: 'manager' }
When method patch
Then status 200
And match response.role == 'manager'
* def cleanup = { userId: newUser.userId }
# teardown
Given path 'users', newUser.userId
When method delete
Then status 204
Scenario: List users returns an array matching schema
Given path 'users'
When method get
Then status 200
And match response == '#array'
And match each response == userSchema
Scenario: Delete a non-existent user returns 404
Given path 'users', 'non-existent-id-99999'
When method delete
Then status 404
And match response.error == '#string'
Scenario: Unauthenticated request returns 401
* configure headers = {}
Given path 'users'
When method get
Then status 401
Scenario: Member token cannot list all users — returns 403
* def memberLogin = call read('classpath:common/login.feature') { email: 'member@teamhub.io', password: 'MemberPass1' }
* configure headers = { Authorization: 'Bearer ' + memberLogin.authToken }
Given path 'users'
When method get
Then status 403Six scenarios. Each is independent. The schema is loaded once in Background and reused across scenarios. The teardown DELETE in the second scenario shows the inline cleanup pattern. The last two scenarios demonstrate auth matrix testing — the 401 and 403 are equally important to the suite as the happy-path cases.
Step 7 — users/users-data-driven.feature
Feature: User Creation — Validation Cases
Scenario Outline: User creation with various inputs
Given url baseUrl
And path 'users'
And request { name: '<name>', email: '<email>', role: '<role>' }
When method post
Then status <expectedStatus>
Examples:
| read('test-users.csv') |test-users.csv:
name,email,role,expectedStatus
Alice,alice@valid.com,admin,201
Bob,bob@valid.com,member,201
Charlie,charlie@valid.com,manager,201
,missing-name@test.com,admin,400
valid-name@test.com,not-an-email,member,422
Alice,alice@valid.com,superadmin,422
Six rows, six independent test executions. Valid cases get 201. Missing name gets 400. Invalid email gets 422. Invalid role gets 422. All from one scenario template and one CSV file.
Step 8 — ParallelRunner.java
package com.teamhub;
import com.intuit.karate.Results;
import com.intuit.karate.Runner;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class ParallelRunner {
@Test
void runAll() {
Results results = Runner
.path("classpath:auth", "classpath:users",
"classpath:teams", "classpath:memberships")
.outputCucumberJson(true)
.parallel(4);
assertEquals(0, results.getFailCount(), results.getErrorMessages());
}
}Four packages, four threads, Cucumber JSON enabled. The assertEquals line is the quality gate — it fails the JUnit test (and therefore the Maven build) if any Karate scenario fails. The error message from getErrorMessages() shows which scenarios failed, directly in the Maven output.
Step 9 — GitHub Actions workflow
name: TeamHub API Tests
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
schedule:
- cron: '0 2 * * 1-5'
jobs:
api-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
cache: maven
- name: Run Karate suite
run: mvn clean verify -Dtest=ParallelRunner
env:
KARATE_ENV: staging
API_BASE_URL: ${{ secrets.STAGING_API_URL }}
ADMIN_EMAIL: ${{ secrets.STAGING_ADMIN_EMAIL }}
ADMIN_PASSWORD: ${{ secrets.STAGING_ADMIN_PASSWORD }}
- name: Upload reports
if: always()
uses: actions/upload-artifact@v4
with:
name: karate-reports-${{ github.run_number }}
path: |
target/karate-reports/
target/cucumber-html-reports/
retention-days: 30mvn clean verify runs tests and the Masterthought plugin in one command. Secrets injected via env:. Reports uploaded on success and failure via if: always().
The build flow
Step 1 of 6
pom.xml + skeleton
One dependency, testResources block, Masterthought plugin. Run 'mvn dependency:resolve' to confirm Karate downloads. Write one trivial GET scenario and confirm it goes green.
⚠️ Common mistakes
karate.callSingle()vscallonce— picking the wrong one.karate.callSingle()inkarate-config.jsruns once per suite (cached across threads).calloncein Background runs once per feature file. For global auth that all features share, usekarate.callSingle()inkarate-config.js. For feature-level setup that's expensive, usecalloncein Background. Mixing them up results in either repeated logins or stale tokens.- Schema files that use string markers for number fields.
"id": "#string"passes even when the API returns a number forid, because JSON schema coercion makes the number passable as a string in some cases. Use"id": "#uuid"if the field is a UUID, or"id": "#number"if it's an integer. Precise markers catch more regressions. - Not running the Scenario Outline failure cases. Scenario Outline is easiest to write for positive cases. The negative rows — missing name, invalid email, bad role — are where validation bugs hide. Write them before the positive ones; if the API is stubbed, add the appropriate 400/422 stub responses so the outline tests something real.
🎯 Practice task
Use the walkthrough as a reference while building your own project. The practice is the project itself — no smaller exercise captures the integration.
When you're stuck on a specific component:
- Auth not working: re-read Step 2 and Step 3. Check that
loginResult.authTokenis the correct field name from your login response. Add* print loginResultinkarate-config.jsas a debug line and run withmvn test— the value appears in the console. - Schema validation failing: run the GET in isolation with
* print responsebefore the match. Compare what the API actually returns against what your schema expects. Adjust the markers to match the real shape. - Parallel runner finding no tests: confirm all feature files are in the packages listed in
Runner.path(...). The pathclasspath:usersfindssrc/test/java/users/*.feature. A typo in the path finds nothing and the runner reports zero scenarios executed. - CI failing on env vars: add a
* print baseUrlstep to one scenario and run the CI job. The printed value in the Actions log confirms whether the env var was injected correctly.
The next lesson is the review and stretch goals — check it once your suite is green to find the remaining opportunities.