Guided Walkthrough — Feature Files, Data-Driven Tests, Assertions

12 min read

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.token

The 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 = email

The 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 403

Six 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: 30

mvn 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() vs callonce — picking the wrong one. karate.callSingle() in karate-config.js runs once per suite (cached across threads). callonce in Background runs once per feature file. For global auth that all features share, use karate.callSingle() in karate-config.js. For feature-level setup that's expensive, use callonce in 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 for id, 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.authToken is the correct field name from your login response. Add * print loginResult in karate-config.js as a debug line and run with mvn test — the value appears in the console.
  • Schema validation failing: run the GET in isolation with * print response before 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 path classpath:users finds src/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 baseUrl step 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.

// tip to track lessons you complete and pick up where you left off across devices.