docs/MIGRATION_GUIDE.md
Karate v2 is a complete ground-up rewrite with significant improvements across the board. Here are the highlights:
| Improvement | Description | Commit |
|---|---|---|
| Embedded JS Engine | Fast hand-rolled lexer/parser with ES6+ support, focused on the Java interop use-case, see benchmark | 90d6e07 |
| Virtual Threads | Java 21+ unlocks massive parallelism with minimal overhead | - |
| @lock Tag | Scenario-level mutual exclusion for parallel safety (@lock=name) | a08337b |
| @lock=* | Exclusive execution - scenario runs alone | cd94b11 |
| Improvement | Description | Commit |
|---|---|---|
| match within | Frequently requested - assert that a value falls within a range | 8535be0 |
| karate.faker.* | Built-in test data generation: firstName(), email(), randomInt(), etc. | 245c540 |
| karate.expect() | Chai-style BDD assertions - easier migration from Postman! | ad2f475 |
| karate.uuid() | Generate random UUIDs | cb516d4 |
| Improvement | Description | Commit |
|---|---|---|
| Apache HttpClient 5.6 | Modern HTTP client with Brotli compression support | 1a35bcd |
| Declarative Auth | configure auth for Basic, Bearer, and OAuth2 with automatic token refresh | 1a06c64 |
| Improvement | Description | Commit |
|---|---|---|
| Mock Server Rewrite | New JS engine and rewritten from scratch for performance - see MOCKS.md | d84c0e4 |
| Improvement | Description | Commit |
|---|---|---|
| Gatling 3.14 | Re-implemented load testing with pure Java architecture | 32f8b00 |
| Improvement | Description | Commit |
|---|---|---|
| CDP Driver Rewrite | Complete reimplementation using Chrome DevTools Protocol directly | 68111e5 |
| PooledDriverProvider | Automatic browser pooling for parallel UI automation | b140436 |
| Auto-wait | Automatic waiting before element operations reduces flaky tests | 67e4c2d |
| Improvement | Description | Commit |
|---|---|---|
| Unified Event System | Single RunListener API for observing and controlling test execution - see DESIGN.md | f4240a2 |
| JSONL Streaming | Memory-efficient karate-results.jsonl format with real-time progress | f4240a2 |
| Modern HTML Reports | Bootstrap 5.3 with dark mode, interactive tag filtering | 3b965b6 |
| JUnit 6 Integration | Streaming dynamic test generation via @TestFactory | a794b02 |
| Improvement | Description | Commit |
|---|---|---|
| Compatibility Shims | com.intuit.karate package delegates to v2 | fefb91f |
| Drop-in Migration | Most v1 code works with just dependency update | - |
Centralized authentication configuration supporting multiple auth types:
// Basic Auth
configure auth = { type: 'basic', username: 'user', password: 'pass' }
// Bearer Token
configure auth = { type: 'bearer', token: 'your-token' }
// OAuth2 Client Credentials
configure auth = { type: 'oauth2', tokenUrl: 'https://auth.example.com/token', clientId: '...', clientSecret: '...' }
// OAuth2 Authorization Code (PKCE)
configure auth = { type: 'oauth2', flow: 'authorization_code', authUrl: '...', tokenUrl: '...', clientId: '...' }
If you're migrating from Postman or other frameworks using Chai-style JavaScript syntax, karate.expect() provides a familiar API:
karate.expect(response.name).to.equal('John')
karate.expect(response.age).to.be.above(18)
karate.expect(response).to.have.property('email')
karate.expect(response.tags).to.include('active')
karate.expect(response.status).to.not.equal('deleted')
Karate v2 includes backward compatibility shims that allow most v1 code to work with minimal changes. For most users, the only change required is updating the Maven dependency.
<!-- v1 -->
<dependency>
<groupId>io.karatelabs</groupId>
<artifactId>karate-junit5</artifactId>
<version>1.5.2</version>
<scope>test</scope>
</dependency>
<!-- v2 -->
<dependency>
<groupId>io.karatelabs</groupId>
<artifactId>karate-junit6</artifactId>
<version>2.0.4</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.1</version>
<scope>test</scope>
</dependency>
Note: Unlike
karate-junit5which bundled JUnit,karate-junit6declares JUnit as aprovideddependency, giving you control over the JUnit version. You must addjunit-jupiterexplicitly.
Reference: See the karate-demo migration commit for a complete example of dependency changes.
Karate v2 requires Java 21+ for virtual threads support.
<properties>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
</properties>
That's it for most projects. Run your tests and they should work.
The following v1 APIs work without code changes via deprecated shims:
| v1 Class | Status |
|---|---|
com.intuit.karate.Runner | Works - delegates to v2 |
com.intuit.karate.Results | Works - wraps v2 SuiteResult |
com.intuit.karate.core.MockServer | Works - delegates to v2 |
com.intuit.karate.junit5.Karate | Works - delegates to v2 |
// This v1 code works in v2 without modification
import com.intuit.karate.Results;
import com.intuit.karate.Runner;
Results results = Runner.path("classpath:features")
.tags("~@ignore")
.parallel(5);
assertTrue(results.getFailCount() == 0, results.getErrorMessages());
// This v1 code works in v2 without modification
import com.intuit.karate.core.MockServer;
MockServer server = MockServer
.feature("classpath:mock.feature")
.arg("key", "value")
.http(0).build();
While the v1 shims work, we recommend migrating to the native v2 APIs to avoid deprecation warnings and take advantage of new features.
// v1 (deprecated shim)
import com.intuit.karate.Results;
import com.intuit.karate.Runner;
Results results = Runner.path("classpath:features")
.tags("~@ignore")
.parallel(5);
assertTrue(results.getFailCount() == 0, results.getErrorMessages());
// v2 (native)
import io.karatelabs.core.Runner;
import io.karatelabs.core.SuiteResult;
SuiteResult result = Runner.path("classpath:features")
.tags("~@ignore")
.parallel(5);
assertEquals(0, result.getScenarioFailedCount(), String.join("\n", result.getErrors()));
Key differences:
Results → SuiteResultgetFailCount() → getScenarioFailedCount()getErrorMessages() → getErrors() (returns List<String>)// v1 (deprecated shim)
import com.intuit.karate.core.MockServer;
MockServer server = MockServer
.feature("classpath:mock.feature")
.arg("key", "value")
.http(0).build();
int port = server.getPort();
// v2 (native)
import io.karatelabs.core.MockServer;
MockServer server = MockServer
.feature("classpath:mock.feature")
.arg(Map.of("key", "value"))
.pathPrefix("/api")
.start();
int port = server.getPort();
server.stopAndWait(); // clean shutdown
Key differences:
.http(0).build() → .start() (port 0 is the default for dynamic port).arg("key", "value") → .arg(Map.of("key", "value")).pathPrefix("/api") strips path prefix from incoming requests before matching.stopAndWait() or .stopAsync() for cleanup// v1 (deprecated shim)
import com.intuit.karate.junit5.Karate;
class SampleTest {
@Karate.Test
Karate testAll() {
return Karate.run("sample").relativeTo(getClass());
}
}
// v2 (native)
import io.karatelabs.junit6.Karate;
import org.junit.jupiter.api.DynamicNode;
class SampleTest {
@Karate.Test
Iterable<DynamicNode> testAll() {
return Karate.run("sample").relativeTo(getClass());
}
}
Key difference: Return type changes from Karate to Iterable<DynamicNode> for JUnit 6 dynamic test support.
// v1
import static com.intuit.karate.gatling.javaapi.KarateDsl.*;
// v2
import static io.karatelabs.gatling.KarateDsl.*;
The DSL methods (karateProtocol, karateFeature, karateSet, uri) are the same — only the package changes.
Java 17+ --add-opens requirement. Gatling 4.7+ calls MethodHandles.privateLookupIn on java.lang internals, which the JVM blocks unless java.base/java.lang is opened to unnamed modules. Add this to the gatling-maven-plugin configuration:
<plugin>
<groupId>io.gatling</groupId>
<artifactId>gatling-maven-plugin</artifactId>
<version>4.7.0</version>
<configuration>
...
<jvmArgs>
<jvmArg>--add-opens=java.base/java.lang=ALL-UNNAMED</jvmArg>
</jvmArgs>
</configuration>
</plugin>
Without it the simulation crashes on startup with IllegalAccessException: module java.base does not open java.lang to unnamed module.
V2 unifies the v1 logging knobs (logPrettyRequest, logPrettyResponse, printEnabled, lowerCaseResponseHeaders, logModifier) under a single configure logging bucket. The shape:
configure logging = {
report: 'debug', // threshold for report-buffer capture (default DEBUG)
console: 'info', // threshold for SLF4J/console output (default INFO)
pretty: true, // pretty-print HTTP JSON bodies (default true)
mask: { // HTTP-only redaction (replaces v1 HttpLogModifier)
headers: ['Authorization', 'Cookie'],
jsonPaths: ['$.password', '$..token'],
patterns: [{ regex: '\\bBearer [A-Za-z0-9._-]+\\b', replacement: 'Bearer ***' }],
replacement: '***',
enableForUri: function(uri) { return uri.indexOf('/health') < 0 }
}
}
See DESIGN.md § Logging for the full shape and semantics.
Two channels, two thresholds. The HTML report has full bodies by default — you do not need to set anything to see them.
| What you want | report | console | Notes |
|---|---|---|---|
| Bodies in HTML report, quiet console (default) | 'debug' | 'info' | One-liner per request on stdout, full headers + body in HTML / JSONL / JUnit / Cucumber. |
| Bodies on console too | 'debug' | 'trace' | Streams full request + body to stdout. Noisy — use locally for debugging, not CI. |
| Headers on console (no body), bodies in report | 'debug' | 'debug' | Useful when you want to see what URL/headers fired without dumping bodies on screen. |
| Quiet report and console | 'warn' | 'warn' | HTTP request lines drop out of HTML too. Last resort for sensitive runs — see also @report=false below. |
Why this differs from v1. v1 wired a single Logback level for everything, and emitted full bodies at DEBUG. v2 splits report-buffer threshold from console threshold, and the console is auto-tiered: INFO = one-liner, DEBUG = headers, TRACE = body. If your v1 muscle memory was "set DEBUG to see bodies in the terminal," in v2 just open target/karate-reports/karate-summary.html — the bodies are already there.
| V1 | V2 equivalent |
|---|---|
configure logPrettyRequest = true/false | configure logging = { pretty: true/false } — single boolean for both directions |
configure logPrettyResponse = true/false | configure logging = { pretty: true/false } — same single boolean |
configure printEnabled = false | configure logging = { report: 'warn' } — raise threshold to drop INFO print/karate.log lines |
configure lowerCaseResponseHeaders = true | Dropped. match header X-Foo is already case-insensitive; use karate.lowerCase(responseHeaders) for direct map access |
configure logModifier = MyImpl | configure logging = { mask: {...} } — declarative map, no Java class needed |
configure report = { logLevel: 'warn' } | Hard-removed in 2.0.6 — use configure logging = { report: 'warn' } |
Manual LoggerFactory.getLogger('com.intuit.karate').setLevel(...) for mid-test silencing | * configure logging = { console: 'error' } — auto-restored at scenario end |
The four v1 keys above (logPrettyRequest, logPrettyResponse, printEnabled, lowerCaseResponseHeaders) are silent no-ops with deprecation warnings — your tests still run, you just see a one-line WARN per process pointing at the new shape. logModifier likewise warns; rewrite the masking declaratively as shown above.
| V2 < 2.0.6 | V2 ≥ 2.0.6 |
|---|---|
--report-log-level | --log-report |
--runtime-log-level | --log-console |
karate-pom.json output.logLevel is replaced by a top-level logging block — using the old key now raises a migration error:
{
"logging": {
"report": "debug",
"console": "info"
}
}
@report=false is backTag a scenario @report=false to keep it in the run (counts toward suite totals) but suppress its step detail from HTML / JUnit XML / Cucumber JSON / JSONL outputs. Use this for runs where step content (HTTP bodies, error messages) may include secrets that mustn't reach CI artifacts.
output suppressed by @report=false (full detail in runtime logs).--log-console), so local debugging works.@report=false scenario.@report=false
Scenario: warmup with sensitive credentials
* call read('classpath:auth/login.feature')
Most feature files work unchanged. Known differences:
.example.com → example.com).def / set / configure / match RHS that starts with { or [ is parsed as Karate's relaxed JSON. To force JavaScript / ES6 semantics on the value side, wrap the literal in parens. See below.Anything on the right-hand side of def (or set, configure, match, …) that starts with { or [ goes through Karate's relaxed JSON parser — hyphenated keys work, #(expr) is substituted, and a bare identifier on the value side is read as the string with that name. To get JavaScript / ES6 evaluation instead, wrap the literal in parens.
* def id = 123
* def name = 'sample'
# Karate JSON — #(...) substitution preserves types
* def a = { "id": #(id), "name": "sample" }
* def b = { id: '#(id)', name: 'sample' } # equivalent
# Hyphenated keys are fine
* def headers = { Accept: 'application/json', Content-Type: 'application/json', Idempotency-Key: 'abc-123' }
# Paren-wrap forces JavaScript evaluation
* def c = ({ id, name }) # ES6 shorthand
* def d = ({ id: id, name: name }) # explicit reference
This matches v1 behavior — most v1 feature files work unchanged. The only thing to watch for is feature/test code that intentionally relied on JS semantics for an unwrapped literal (e.g., * def response = { id: pathParams.id }) — those need paren-wrapping (* def response = ({ id: pathParams.id })) or rewriting with #(...).
V2 uses a rewritten driver with CDP (Chrome DevTools Protocol) as the primary backend and full W3C WebDriver support for cross-browser testing.
// karate-config.js - minimal config
function fn() {
karate.configure('driver', { type: 'chrome', headless: false });
return { serverUrl: 'http://localhost:8080' };
}
Key differences from v1:
showDriverLog has no effect (TODO)chromedriver, geckodriver, safaridriver, msedgedriversubmit() has been ported from v1 and works on all backends| Type | Backend | Description |
|---|---|---|
chrome | CDP | Chrome/Chromium/Edge via DevTools Protocol (recommended for development) |
chromedriver | W3C | Chrome via chromedriver |
geckodriver | W3C | Firefox via geckodriver |
safaridriver | W3C | Safari (macOS only) |
msedgedriver | W3C | Microsoft Edge |
All v1 driver keywords work the same way:
* driver serverUrl + '/login'
* input('#username', 'admin')
* click('button[type=submit]')
* waitFor('#dashboard')
* match driver.title == 'Welcome'
V2 preserves V1 behavior for shared-scope calls (* call read('feature')): if a called feature creates a driver, it automatically propagates back to the caller. No special configuration is needed.
# login.feature — driver propagates to caller automatically
@ignore
Feature: Login
Background:
* configure driver = { type: 'chrome' }
Scenario: Login
* driver serverUrl + '/login'
* input('#username', 'admin')
* input('#password', 'secret')
* click('#submit')
* waitFor('#dashboard')
# main.feature — driver is available after call returns
Scenario: Full regression
* call read('classpath:pages/login.feature')
* delay(5000) # ✅ works — driver propagated from login
* call read('classpath:pages/dashboard.feature') # ✅ works — driver is shared
Note: Early v2 releases required
scope: 'caller'in the driver config. This is no longer needed and can be safely removed.
These functions are only available after driver 'url' has been called (i.e., a browser session is active):
click(), input(), clear(), focus(), scroll(), select(), submit(), text(), html(), value(), attribute(), exists(), enabled(), position(), locate(), locateAll(), waitFor(), waitForText(), waitForUrl(), waitUntil(), screenshot(), highlight(), delay(), script(), scriptAll(), mouse(), keys(), switchFrame(), switchPage(), refresh(), back(), forward()
If you see <function> is not defined, check that the driver was initialized before that line.
For a delay without a driver, use karate.pause(millis) instead of delay(millis).
V2 automatically pools browser instances using PooledDriverProvider. This is the default — no configuration needed:
Runner.path("features/")
.parallel(4); // Pool of 4 drivers auto-created
Benefits:
See DRIVER.md for detailed DriverProvider documentation.
V2 drops v1's tree-walking element accessors — parent, children, firstChild, lastChild, previousSibling, nextSibling — by design. Hop-counting patterns like e.parent.parent are fragile: any intervening <div> added by a designer silently breaks the test. The v2 surface is selector-based and mirrors the native W3C DOM Element API:
| API | Purpose |
|---|---|
element.closest(selector) | Nearest ancestor (or self) matching a CSS selector |
element.matches(selector) | Boolean: does this element match the selector |
element.locate(childSelector) / element.locateAll(childSelector) | Scoped descendant lookups |
element.script(jsExpression) | Escape hatch for arbitrary DOM walks (_.nextElementSibling.id, etc.) |
V1 → V2 translation recipes:
# v1: row.parent.click() — v2: closest + a selector
* locate('//td[text()="John"]').closest('tr').click()
# v1: el.parent.children → v2: closest + scoped locateAll
* def cells = locate('//td[text()="John"]').closest('tr').locateAll('td')
# v1: el.nextSibling — v2: script() drops into the browser
* def nextId = locate('#anchor').script('_.nextElementSibling.id')
scope: 'caller' from driver config if present (no longer needed)delay(millis) with karate.pause(millis) if used before the driver startsshowDriverLog has no effect (TODO)chromedriver, geckodriver, safaridriver) are now fully supportedelement.parent, .children, .nextSibling, etc.) in terms of closest(selector) + scoped locateAll, or element.script() for arbitrary DOM walksThe v1 embedded HTTP server (com.intuit.karate.http.HttpServer with ServerConfig and contextFactory) has been replaced with a new architecture in v2:
io.karatelabs.http.ServerRequestHandler with file-based API routing (/api/todos → api/todos.js)/api/todos/{id} resolves to api/todos.js with request.pathMatches() for path parameterscontextFactory pattern for manual path routing does not exist in v2useGlobalSession(true) is replaced by explicit session init in JS handlers: session || context.init()HttpServer.config(...).build() → HttpServer.start(port, handler)// v1
ServerConfig config = new ServerConfig("src/main/java/app").useGlobalSession(true);
config.contextFactory(request -> {
ServerContext context = new ServerContext(config, request);
if (context.setApiIfPathStartsWith("/api/")) {
context.setLockNeeded(true);
}
return context;
});
HttpServer server = HttpServer.config(config).http(8080).build();
// v2
ServerConfig config = new ServerConfig("src/main/java/app")
.sessionStore(new InMemorySessionStore()) // required for context.init() to do anything
.csrfEnabled(false); // on by default; turn off for API-only test backends
// /api/* and /pub/* routing is built in — no contextFactory
ServerRequestHandler handler = new ServerRequestHandler(config, new RootResourceResolver(config.getResourceRoot()));
HttpServer server = HttpServer.start(8080, handler);
Two easy-to-miss side effects of the change:
SessionStore is configured. config.isSessionEnabled() returns true only when sessionStore(...) has been called. Without it, context.init() is a silent no-op and every JS handler dereferencing session.foo throws cannot read properties of null. InMemorySessionStore is the drop-in for dev and single-instance apps.csrfEnabled(false); for web apps, use csrfExemptPaths(...) for webhook/API routes that authenticate differently.If your tests used the embedded HTTP server as a test backend, consider switching to MockServer with a feature file — this is the idiomatic v2 approach for test API backends. See karate-todo for a complete example.
For serving full web applications with templates, see TEMPLATING.md.
karate-junit5 → karate-junit6 dependencyjunit-jupiter dependency explicitly (v2 doesn't bundle it)maven-surefire-plugin to 3.2.5+ (for JUnit 6 support)--add-opens=java.base/java.lang=ALL-UNNAMED to its <jvmArgs>JsonUtils with Json class (if used)HttpLogModifier, WebSocketClient, or Driver Java APIs (if used)HttpServer.start(port, ServerRequestHandler), add a SessionStore, and decide whether to keep CSRF onEach shim provides a toV2*() method if you want to migrate incrementally:
// Get underlying v2 Builder
io.karatelabs.core.Runner.Builder v2Builder = v1Builder.toV2Builder();
// Get underlying v2 MockServer
io.karatelabs.core.MockServer v2Server = v1Server.toV2MockServer();
// Get underlying v2 SuiteResult
io.karatelabs.core.SuiteResult v2Results = v1Results.toSuiteResult();
For a complete reference CI/CD pipeline that runs API tests, UI tests (via a containerized Chrome), Gatling smoke, secret scanning, and publishes HTML reports to GitHub Pages, see karatelabs/karate-todo.
The UI side uses chromedp/headless-shell + Testcontainers.exposeHostPorts(...) to reach an
in-process Karate-hosted app from inside the browser container. The critical pattern:
// src/test/java/app/ui/UiTest.java
private static final int PORT = 18080;
static {
// Docker 29.x API negotiation workaround
System.setProperty("api.version", "1.44");
}
@BeforeAll
static void beforeAll() {
server = App.start(App.serverConfig("src/main/java/app"), PORT);
chrome = new ChromeContainer(); // extends GenericContainer
chrome.start();
}
@Test
void testAll() {
ContainerDriverProvider provider = new ContainerDriverProvider(chrome);
SuiteResult result = Runner.path("classpath:app/ui")
.tags("~@external", "~@todo")
.systemProperty("serverUrl", chrome.getHostAccessUrl(PORT))
.systemProperty("apiUrl", "http://localhost:" + PORT)
.driverProvider(provider)
.parallel(1);
assertEquals(0, result.getScenarioFailedCount(), String.join("\n", result.getErrors()));
}
ContainerDriverProvider is a thin extension of PooledDriverProvider that overrides createDriver(config)
to call CdpDriver.connect(container.getCdpUrl(), CdpDriverOptions.fromMap(config)) — each pooled slot gets a
fresh tab in the shared container. Full source:
app/ui/support/.
We've migrated two projects as reference implementations. These commits show all the changes needed:
Commit: c8fca97ce
This involved additional infrastructure changes beyond what typical end-users need:
javax.* → jakarta.* servlet importsCommit: 7ffe47509
This shows a simpler migration focused on test dependencies and runner changes.
Repository: karatelabs/karate-todo
A complete v1 → v2 migration using native v2 APIs (no shims):
karate-junit5 → karate-junit6 with Iterable<DynamicNode> return typeMockServer for test backendResults → SuiteResult with updated method namescom.intuit.karate.gatling → io.karatelabs.gatling importsReference diffs:
--add-opens), App.java
(v2 HttpServer.start + ServerRequestHandler + SessionStore), JS handlers (session || context.init()),
templates (absolute /pub paths, CDN trinity), JUnit 6 runners, Gatling package rename.Add Testcontainers UI runner... and
Add GitHub Actions CI... commits on main.