Skip to main content

Distributing Test Execution with SBT: A Complete Guide to Parallel CI/CD

Picture of Jacek Bizub, Software Engineer

Jacek Bizub

Software Engineer
Aug 27, 2025|19 min read
mirage_code
1// Define a custom task
2lazy val parTestGroup = inputKey[Unit]("Runs a single test group")
3parTestGroup := (Def.inputTaskDyn {
4 // Takes two parameters: groupId (which group to run) and numberOfGroups (total groups)
5 val List(groupId, numberOfGroups) = complete.DefaultParsers
6 .spaceDelimited("<arg>")
7 .parsed
8 .map(_.toInt)
9
10 // Retrieves all available tests
11 val allTests = (Test / definedTests).value
12
13 // Calculates how many tests should be in each group
14 val numberOfTests = allTests.size
15 val numberOfTestsPerGroup =
16 if (numberOfTests % numberOfGroups == 0) {
17 numberOfTests / numberOfGroups
18 } else { (numberOfTests / numberOfGroups) + 1 }
19
20 // Divides tests into groups
21 val groups = allTests.grouped(numberOfTestsPerGroup).toArray
22
23 val groupToRun = groups(groupId - 1)
24 val argForTestOnly = " " + groupToRun.map(_.name).mkString(" ")
25
26 streams.value.log.info(s"Running testOnly:$argForTestOnly")
27
28 // Runs only the specified group using SBT's testOnly task
29 Def.taskDyn {
30 (Test / testOnly).toTask(argForTestOnly)
31 }
32}).evaluated
1sbt "parTestGroup 1 3"
1sbt:root> parTestGroup 1 3
2[info] Running testOnly: io.github.stivens.example.suites.TestSuite5 io.github.stivens.example.suites.TestSuite7 io.github.stivens.example.suites.TestSuite2 io.github.stivens.example.suites.TestSuite6
1sbt "parTestGroup 3 3"
1sbt:root> parTestGroup 3 3
2[info] Running testOnly: io.github.stivens.example.suites.TestSuite8 io.github.stivens.example.suites.TestSuite3
1jobs:
2 # Phase 1: Compile once
3 compilation:
4 name: Compile the project
5 runs-on: ubuntu-latest
6 timeout-minutes: 15
7 steps:
8 - name: Checkout
9 uses: actions/checkout@v4
10 - name: Setup JDK 21, Scala, SBT
11 uses: ./.github/actions/setup-scala
12 - name: Compile
13 uses: ./.github/actions/compile
14
15 # Phase 2: Run test groups in parallel
16 tests-group-1:
17 name: Run tests (1 of 10)
18 needs: [compilation]
19 uses: ./.github/workflows/run-tests-group.yml
20 with:
21 group_id: 1
22 num_groups: 10
23
24 tests-group-2:
25 name: Run tests (2 of 10)
26 needs: [compilation]
27 uses: ./.github/workflows/run-tests-group.yml
28 with:
29 group_id: 2
30 num_groups: 10
31
32 # ... additional groups ...
33
34 # Phase 3: Aggregate results
35aggregate-all:
36 name: compile and test the project
37 runs-on: ubuntu-latest
38 needs: [compilation, tests-group-1, tests-group-2, tests-group-3, tests-group-4, tests-group-5, tests-group-6, tests-group-7, tests-group-8, tests-group-9, tests-group-10]
39 if: ${{ always() }} # do not skip if one of the actions above has failed!
40 env:
41 everything_ok: ${{ !contains(needs.*.result, 'failure') && (!contains(needs.*.result, 'skipped') || needs.compilation.result == 'skipped') }}
42 steps:
43 - name: The workflow has succeeded
44 if: ${{ env.everything_ok == 'true' }}
45 run: exit 0
46 - name: The workflow has failed
47 if: ${{ env.everything_ok == 'false' }}
48 run: exit 1
1# .github/workflows/run-tests-group.yml
2on:
3 workflow_call:
4 inputs:
5 group_id:
6 required: true
7 type: string
8 num_groups:
9 required: true
10 type: string
11
12jobs:
13 run_tests_group:
14 name: tests
15 runs-on: ubuntu-latest
16 timeout-minutes: 10
17 steps:
18 - uses: actions/checkout@v4
19 - uses: ./.github/actions/setup-scala
20 - uses: ./.github/actions/restore-compilation-cache
21 - name: Run tests
22 run: sbt 'parTestGroup ${{ inputs.group_id }} ${{ inputs.num_groups }}'
sbt_screenshot_1

sbt_screenshot_2

1Speedup = 1 / ((1 - P) + P/N)

Subscribe to our newsletter and never miss an article