Why Is Your Test Automation So Slow? (And How to Fix It)
A test suite that takes 45 minutes to run doesn't get run. Slow automation defeats its own purpose. Here are the real causes of slow test suites and concrete fixes for each one.
On this page
A test suite that takes 45 minutes to run will eventually stop being run before every merge. Engineers start skipping it. The CI step becomes optional. Slow automation is failed automation.
Here are the actual causes — and fixes.
Cause 1: Sequential Execution
The default for most test runners is sequential: run test 1, then test 2, then test 3. For a suite of 500 tests averaging 2 seconds each, that's over 16 minutes — from sequential execution alone.
Fix: Parallelize
Most modern test runners support parallel execution. In JUnit 5:
// junit-platform.properties
junit.jupiter.execution.parallel.enabled=true
junit.jupiter.execution.parallel.mode.default=concurrent
junit.jupiter.execution.parallel.config.strategy=dynamicIn Gradle:
tasks.withType<Test> {
maxParallelForks = Runtime.getRuntime().availableProcessors() / 2
}[!WARNING] Parallel tests must be completely isolated — no shared state, no shared database rows, no shared files. Parallelizing tests with shared state introduces flakiness.
Cause 2: Too Many End-to-End Tests
UI/E2E tests are 10-60x slower than unit tests. A suite dominated by Espresso or Selenium tests will always be slow.
Fix: Move logic down the stack
Before writing a UI test, ask: can this be tested at the unit or API level?
- A business rule validation → unit test
- An API response format → API test
- A navigation flow → UI test (justified)
- Whether a button is disabled when form is empty → UI test (justified)
Most coverage of application logic doesn't require a browser or device.
Cause 3: Slow Test Setup and Teardown
If every test restarts a database, clears all data, seeds fresh fixtures, and then runs a single assertion, the test itself is a tiny fraction of the total runtime.
Fix: Share expensive setup, isolate cheap teardown
companion object {
@JvmStatic
@BeforeAll
fun setupDatabase() {
// Expensive: runs once for the test class
testDatabase = startTestDatabase()
seedBaseData(testDatabase)
}
}
@BeforeEach
fun resetMutatedData() {
// Cheap: only reset what this test changes
testDatabase.rollback(lastTransaction)
}Use
@BeforeAll@BeforeEachCause 4: Real HTTP Calls in Tests
Tests that call real external services — payment gateways, email providers, third-party APIs — are slow by definition. Network latency, rate limits, and external availability all affect them.
Fix: Mock external dependencies
// Instead of calling the real payment API
val mockPaymentGateway = mockk<PaymentGateway>()
every { mockPaymentGateway.charge(any()) } returns PaymentResult.Success(transactionId = "test-123")
val orderService = OrderService(paymentGateway = mockPaymentGateway)
orderService.placeOrder(testOrder)
verify { mockPaymentGateway.charge(testOrder.total) }Mocked HTTP calls take microseconds. Real ones take 100ms–2s each.
Cause 5: No Test Result Caching
Running 500 tests when only 3 files changed is wasteful. Gradle's build cache and test caching mean unchanged tests don't re-run.
Fix: Enable Gradle build cache
// gradle.properties
org.gradle.caching=true
org.gradle.configuration-cache=trueTests in unchanged modules are loaded from cache. In large projects, this can reduce test time by 80%+.
Cause 6: Tests That Wait for Time
// Waiting for an email to be "sent" by sleeping
Thread.sleep(3000) // hope the email sends in 3 seconds
assertEmailReceived(address)Arbitrary sleeps are slow and unreliable. They're often set to the worst-case time, not the average time.
Fix: Use event-driven waits
// Poll until condition is true, up to a timeout
awaitUntil(timeout = 5.seconds, interval = 100.milliseconds) {
emailService.hasReceived(address)
}Poll frequently with a reasonable timeout instead of sleeping for the worst case.
Cause 7: Bloated CI Runner Configuration
Each test job spinning up a fresh Docker container, pulling dependencies, warming up the JVM, and running a gradle daemon adds minutes of overhead.
Fix: Cache dependencies in CI
# GitHub Actions example
- uses: actions/cache@v3
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ hashFiles('**/*.gradle*') }}Dependency caching saves 2-5 minutes per CI run. On a team with 20 PRs/day, that's significant.
Benchmarking Your Suite
Before optimizing, measure:
# Run the suite and measure time by module/class
./gradlew test --profile
# Opens an HTML report with timing breakdownFind the 20% of tests taking 80% of the time. Fix those first.
Targets Worth Aiming For
| Suite Size | Target Run Time |
|---|---|
| < 100 tests | < 30 seconds |
| 100–500 tests | < 2 minutes |
| 500–2000 tests | < 5 minutes |
| 2000+ tests | < 10 minutes with parallelism |
If your CI takes longer than these targets, it's actively slowing development. Engineers start batching commits to avoid the wait, which defeats the purpose of CI.
Takeaways
- Parallel execution is the highest-ROI fix for slow suites
- Too many UI/E2E tests is the root cause for most chronically slow suites
- Mock external dependencies — real HTTP calls kill performance
- Cache Gradle/Maven dependencies in CI — this is often overlooked
- Measure before optimizing — find the slowest 20% and start there
Sudarshan Chaudhari
AI Systems Builder / Product Engineer
Bangkok, Thailand
Solo Android developer with 13+ years in QA, building Android apps, AI automation systems, and developer tools at SudarshanTechLabs.
Related Posts
Building something? Available for Android dev and QA consulting.
Work with meComments — powered by Giscus
