Parallelism
Taqwright is serial by default, one Appium, one device. To go parallel you either declare a local device pool and raise workers (or let Taqwright auto-discover the devices for you), or point at a cloud provider where each worker is its own independent session.
Device pools
Taqwright defaults to workers: 1, one Appium + one device, serial. To run tests truly in parallel against multiple devices, declare a device pool and bump workers:
import { defineConfig, Platform } from '@taqwright/taqwright';
export default defineConfig({
workers: 3,
fullyParallel: true,
projects: [{
name: 'android-parallel',
use: {
platform: Platform.ANDROID,
device: {
provider: 'emulator',
pool: [
{ udid: 'emulator-5554', name: 'Pixel_7_API_34' },
{ udid: 'emulator-5556', name: 'Pixel_7_API_34_2' },
{ udid: 'emulator-5558', name: 'Pixel_7_API_34_3' },
],
},
buildPath: './app.apk',
appBundleId: 'com.example.app',
appium: { autoStart: true, port: 4723 },
},
}],
});
How worker partitioning works:
- Worker
Npickspool[N]. A worker whereN >= pool.lengthfails fast with a clear error, never silently double-books a device. - Each worker spawns its own Appium on
basePort + Nwhenappium.autoStart: true, and kills it when the worker tears down. - Driver-specific ports (
appium:systemPort,appium:wdaLocalPort,appium:chromedriverPort,appium:mjpegServerPort) are auto-staggered per worker so two UiAutomator2 / XCUITest sessions don't fight over the same port.
For iOS, the WDA / MJPEG port + DerivedData staggering is handled by iosParallelCaps(), one slot per separate iOS project; pool-driven parallelism within a single iOS project is auto-staggered per worker.
Auto-discover devices
Don't want to hand-write (and maintain) a device.pool? Set device.autoDiscover: true and Taqwright resolves the pool for you, discovering the local devices, cold-booting shutdown AVDs / simulators to reach the workers count, and assigning one per worker:
import { defineConfig, Platform } from '@taqwright/taqwright';
export default defineConfig({
workers: 3,
fullyParallel: true,
projects: [{
name: 'android-parallel',
use: {
platform: Platform.ANDROID,
device: {
provider: 'emulator',
autoDiscover: true, // no pool — resolve & boot 3 AVDs across the 3 workers
},
buildPath: './app.apk',
appBundleId: 'com.example.app',
appium: { autoStart: true, port: 4723 },
},
}],
});
How it resolves:
- Resolved once, up front. A
globalSetuphook (injected automatically when any project opts in) enumerates the host's devices, freezes the assignment, and hands each worker its slot before any worker starts. This avoids the race where two workers independently pick the same device while one is mid-boot. - Cold-boot to reach
workers. Android AVDs are booted on demand by Appium (delegated viaappium:avd); iOS simulators are pre-booted by the hook. Already-running devices are reused. - Fail fast. If fewer devices are available than
workers, the run aborts with a clear message (autoDiscover found 2 devices but workers is 3 …) before any test executes, lowerworkersor start more devices. - What counts as available, per project platform + provider:
emulator+ Android, the active-SDK AVDs (running or shutdown).emulator+ iOS, the available simulators (running or shutdown).local-device+ Android, connected physical handsets only (emulators excluded; physical devices can't be booted, so only what's already plugged in counts).
npx taqwright test --project android-parallel --workers 3
autoDiscover is mutually exclusive with device.pool and device.udid (set one or the other), works only for local providers (cloud grids manage their own queueing), needs appium.autoStartDevice left enabled so AVDs can boot, and does not yet support local-device + iOS (there's no multi-device enumerator for physical iPhones, use device.udid / device.pool there). Each of these is rejected at config load with an actionable error.
workers × fullyParallel × device.pool
fullyParallel only changes Playwright's scheduling granularity (tests-within-a-file vs whole-files per worker), it never raises the concurrent-device count, which is bounded by workers. So the only thing that risks device contention is workers > 1, and that's caught at config load.
workers | fullyParallel | device.pool | Result | What to do |
|---|---|---|---|---|
1 (default) | false (default) | none | Safe. Serial, one Appium + one device. | Nothing, this is the default. |
1 | true | none | Safe. Still serial (one worker). fullyParallel only re-orders the schedule. | Harmless; no real effect with one worker. |
> 1 | any | none | Rejected at config load (unless device.autoDiscover: true). defineConfig throws before any device work. | Add a device.pool with at least workers entries, set device.autoDiscover: true, or set workers: 1. |
> 1 | any | pool, length < workers | Rejected at config load. Under-sized pool. | Grow the pool to at least workers entries, or lower workers. |
> 1 | true | pool, length >= workers | Correct parallel. Each worker gets pool[idx] + its own Appium + staggered ports. | This is the intended parallel setup. |
> 1 | false | pool, length >= workers | Parallel across files only. Workers run different files concurrently; tests within a file stay sequential. | Set fullyParallel: true to also distribute tests within a file. |
Without an adequately sized pool, workers > 1 is rejected with a clear error at config load, taqwright test aborts before any device work, rather than letting multiple workers collide on one Appium session. Either set workers: 1, declare a device.pool with at least workers entries, or set device.autoDiscover: true to let Taqwright resolve them.
Cloud projects (browserstack / lambdatest) have no device.pool and the provider manages its own device queueing, so the config-load check skips them.
Parallel runs on BrowserStack / LambdaTest
Cloud providers need no device.pool. Each Playwright worker is its own OS process and opens its own independent cloud session, so parallelism is plain workers: N + fullyParallel: true, the provider queues device contention on its side.
import { defineConfig, Platform } from '@taqwright/taqwright';
export default defineConfig({
workers: 5, // 5 parallel cloud sessions, no device.pool
fullyParallel: true, // defineConfig defaults to false (serial)
projects: [{
name: 'browserstack',
use: {
platform: Platform.ANDROID,
device: {
provider: 'browserstack', // or 'lambdatest'
name: 'Google Pixel 8',
osVersion: '14.0',
orientation: 'portrait', // optional
},
buildPath: 'bs://<app-id>', // pre-uploaded; see note below
appBundleId: 'com.example.app',
trace: 'on-failure',
video: 'off', // cloud records server-side
},
}],
});
export BROWSERSTACK_USERNAME=... # or LAMBDATEST_USERNAME
export BROWSERSTACK_ACCESS_KEY=... # or LAMBDATEST_ACCESS_KEY
npx taqwright test --project browserstack --workers 5
- Parallelism is
workers: N, nothing else. Nodevice.pool, no per-worker Appium, no driver-port staggering, that machinery is local-only. One worker = one cloud session; pass/fail is pushed to the provider dashboard on teardown. - Credentials come from the environment: never in the config. Missing creds fail the worker fast with the provider's own message.
--workers Noverrides the config value for that run, so you can dial parallelism per-run without editing the config.- Video is recorded server-side and viewed on the provider dashboard, taqwright attaches no mp4 for cloud runs. Trace still works and lands in the HTML report.
resetBetweenTestsstill type-requiresbuildPath+appBundleId, but the on-device terminate/reinstall is skipped, isolation comes from a fresh session per test plus the provider's own reset.
(1) Bound workers to your provider plan's parallel-session limit, extra workers just block waiting for a free slot. (2) The build upload runs once per worker process and the cached URL doesn't cross worker boundaries, so a local .apk/.ipa in buildPath is uploaded N times for N workers. Pre-upload once and reference the returned bs:// / lt:// URL to skip the upload entirely.