Can I Run Unit Tests in Parallel on Windows Runners?

Yes. Raise the test runner's worker count on one larger Windows runner, or shard the suite across several jobs. The two shapes carry different cost profiles.

Last verified:

Yes. A Windows GitHub Actions job runs unit tests in parallel two ways: raise the test runner's worker count so one larger machine executes several tests at once, or shard the suite across several jobs that each run a slice. Both are supported on WarpBuild Windows runners, and they carry different cost profiles, so the choice is an arithmetic question rather than a capability question.

Answer

WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners. The Windows fleet is x86-64 in four shapes, from 4 vCPU with 16GB of memory to 32 vCPU with 128GB, all on 256GB SSD storage (cloud runners documentation, checked on 2026-08-13). The 2 vCPU Windows shapes were removed on June 8, 2026, so 4 vCPU is the smallest machine a test job can start on.

Worker count is the first lever. A test runner that spawns one worker per vCPU turns the machine's cores into parallel test execution, and the memory each worker gets falls out of the shape you pick.

Runner labelvCPUMemoryPer minuteWorkers at one per vCPUMemory per workerCost per worker-minute
warp-windows-latest-x64-4x416GB$0.01644GB$0.004
warp-windows-latest-x64-8x832GB$0.03284GB$0.004
warp-windows-latest-x64-16x1664GB$0.064164GB$0.004
warp-windows-latest-x64-32x32128GB$0.128324GB$0.004

Rates and shapes come from the cloud runners documentation, checked on 2026-08-13. The last column is the row's per-minute rate divided by its vCPU count, and it is $0.004 at every size. That constant is what makes the two parallel shapes comparable: 16 vCPU of Windows compute bills $0.064 per minute whether it arrives as one 16 vCPU job or as four 4 vCPU jobs.

Sharding is the second lever, and it needs concurrency. Run as many jobs as your workflows need. Generally available Linux and Windows runners do not have plan-level concurrency caps, and generally available features support unlimited concurrency on Linux and Windows runners (cloud runners documentation), so a matrix of eight Windows shards starts eight machines rather than queueing behind a fleet limit.

One Windows detail applies to both shapes. WarpBuild caches are not supported for Windows-based runners (cloud runners documentation), so every Windows job restores packages with actions/cache or over the network. That restore is fixed work per job, which is what makes shard count the expensive variable. Every label, image family, and alias is on the Windows runners page.

Detail

Shape one: workers on a single larger runner

One job, one machine, one checkout, one restore, one build. The test runner does the parallel work.

name: windows-tests-single-machine

on:
  pull_request:

jobs:
  test:
    runs-on: warp-windows-latest-x64-16x
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-dotnet@v4
        with:
          dotnet-version: "9.0.x"

      - uses: actions/cache@v4
        with:
          path: ~\.nuget\packages
          key: nuget-${{ runner.os }}-${{ hashFiles('**/packages.lock.json') }}

      - run: dotnet restore --locked-mode

      - run: dotnet build --configuration Release --no-restore

      - name: Run the suite with 16 workers
        env:
          MSBUILDDISABLENODEREUSE: 1
        run: >
          dotnet test --configuration Release --no-build
          -- xUnit.MaxParallelThreads=16 xUnit.ParallelizeAssembly=true

xUnit.MaxParallelThreads and xUnit.ParallelizeAssembly are runner configuration values passed through dotnet test after the -- separator (xUnit parallelism documentation, dotnet test reference). Without ParallelizeAssembly, xUnit parallelizes test collections inside one assembly and runs assemblies one after another, which caps the useful worker count at the widest collection in the slowest assembly.

Shape two: shards across several smaller runners

Four jobs, four machines, a shard index selecting the slice each one runs. The matrix comes from strategy.matrix in the workflow syntax (GitHub Actions workflow syntax).

name: windows-tests-sharded

on:
  pull_request:

jobs:
  test:
    strategy:
      fail-fast: false
      matrix:
        shard: [0, 1, 2, 3]
    runs-on: warp-windows-latest-x64-4x
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-dotnet@v4
        with:
          dotnet-version: "9.0.x"

      - uses: actions/cache@v4
        with:
          path: ~\.nuget\packages
          key: nuget-${{ runner.os }}-${{ hashFiles('**/packages.lock.json') }}

      - run: dotnet restore --locked-mode

      - run: dotnet build --configuration Release --no-restore

      - name: Run shard ${{ matrix.shard }} of 4
        shell: pwsh
        env:
          SHARD_INDEX: ${{ matrix.shard }}
          SHARD_TOTAL: 4
          MSBUILDDISABLENODEREUSE: 1
        run: |
          $projects = Get-ChildItem -Recurse -Filter *.Tests.csproj | Sort-Object FullName
          for ($i = 0; $i -lt $projects.Count; $i++) {
            if ($i % [int]$env:SHARD_TOTAL -ne [int]$env:SHARD_INDEX) { continue }
            dotnet test $projects[$i].FullName --configuration Release --no-build `
              -- xUnit.MaxParallelThreads=4
            if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
          }

fail-fast: false keeps the other three shards running when one fails, which is what you want while a suite is still being made parallel-safe. Sorting the project list keeps the assignment stable across runs so a shard failure is reproducible.

Modulo over a sorted project list is the simplest split and the least balanced one. Splitting by measured duration is what keeps the slowest shard from setting wall clock for everyone; the matrix sharding guide covers building that list from previous run timings.

What the two shapes cost

Assume a suite with 60 core-minutes of test execution and a fixed phase of 4 minutes per job for checkout, package restore, and build. The fixed phase is mostly single-threaded, so it does not shrink when the machine gets bigger. Billing is per minute of runner time, so each job rounds up to a whole minute.

ShapeMachinesTotal vCPUBilled minutes per jobMachine-minutes per runCost per runWall clock
One 16x, 16 workers1 x warp-windows-latest-x64-16x164 + 60/16 = 7.75, billed 88$0.5128 min
Two 8x shards, 8 workers each2 x warp-windows-latest-x64-8x164 + 30/8 = 7.75, billed 816$0.5128 min
Four 4x shards, 4 workers each4 x warp-windows-latest-x64-4x164 + 15/4 = 7.75, billed 832$0.5128 min
Eight 4x shards, 4 workers each8 x warp-windows-latest-x64-4x324 + 7.5/4 = 5.875, billed 648$0.7686 min

The first three rows land on the same bill because they buy the same 16 vCPU at the same $0.004 per vCPU-minute. The fourth row is the real trade: eight shards finish 2 minutes sooner and cost $0.256 more per run, because the 4-minute fixed phase is paid eight times instead of four. At 600 pull request runs a month that is $307.20 against $460.80, a difference of $153.60. Substitute your own fixed phase and core-minutes; the shape of the answer only changes when the fixed phase changes.

Two corrections to apply to your own numbers. First, workers on one machine share one disk, one network interface, and one memory bus, so the test phase rarely divides cleanly by worker count once workers are I/O heavy. Second, shards that finish early stop billing while a single large machine bills its full duration, so a skewed suite loses less money on shards than the table suggests and more wall clock.

Per-minute rates set what all of those minutes are worth. warp-windows-latest-x64-16x (16 vCPU, 64 GB) costs $0.064 per minute against $0.082 per minute for the 16-core Windows larger runner (16 vCPU, 64 GB): 22 percent lower list price. GitHub list price checked on 2026-08-13, from the GitHub Actions minute multipliers reference with shapes from the GitHub-hosted runners reference.

Every Windows rate is on the pricing page.

Where parallel tests break on Windows

A suite that passes serially and fails at 16 workers is usually hitting one of three things.

Port collisions. Tests that start a listener on a fixed port collide the moment two workers do it at once, and the failure surfaces as a socket exception saying only one usage of each socket address is permitted. Bind to port 0 and read the assigned port back from the listener. Windows also holds closed sockets in TIME_WAIT, so a suite that opens and closes many listeners across 16 workers can drain the ephemeral range; run netsh int ipv4 show dynamicport tcp on the runner to print the range the image is configured with (Windows dynamic port range documentation).

Shared temp paths. Every worker in one job inherits the same TEMP and TMP, so a fixture writing a fixed filename under the temp directory races against itself. Inside a single machine the fixture has to create a unique subdirectory per worker. Across shards, isolate at the job level instead, using RUNNER_TEMP (GitHub Actions variables reference):

      - name: Give the shard its own scratch directory
        shell: pwsh
        run: |
          $scratch = Join-Path $env:RUNNER_TEMP "shard-${{ matrix.shard }}"
          New-Item -ItemType Directory -Force -Path $scratch | Out-Null
          "TMP=$scratch"  | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
          "TEMP=$scratch" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8

File locking. Windows holds a mandatory lock on an open file, so a worker deleting or overwriting a file another worker still has open fails with an IOException saying the process cannot access the file. The usual sources are a shared fixture directory, a test that rewrites an assembly on disk, and MSBuild worker nodes that outlive the build and keep assemblies loaded. MSBUILDDISABLENODEREUSE: 1 in the job environment, or -nodeReuse:false on the command line, closes the last one (MSBuild command-line reference).

Once the suite is parallel-safe, size it from data rather than from guesses. The Jobs report gives duration P75 and P90 plus CPU P75 and memory P75 for each repository, workflow, and job name, and the CPU and memory columns need Observability enabled (reports documentation). Raise the worker count while CPU P75 climbs, and stop when memory P75 approaches the shape's 4GB per vCPU.

How many test workers should I run on one Windows runner?

Start at one worker per vCPU, which is 4 workers on warp-windows-latest-x64-4x and 16 on warp-windows-latest-x64-16x. Every Windows shape carries 4GB of memory per vCPU, so that setting also gives each worker 4GB. Then read CPU P75 and memory P75 for the job in the reports documentation surfaces and move the number until CPU sits high without memory pressure. Shapes and rates for each label are on the Windows runners page.

Does sharding across jobs cost more than one larger runner?

Not by itself. The Windows ladder prices at a constant $0.004 per vCPU-minute, so 16 vCPU costs $0.064 per minute whether it is one 16 vCPU job or four 4 vCPU jobs. The cost rises when you add shards, because every extra shard repeats the checkout, restore, and build phase and pays for it again. Model your own split against the rates on the pricing page and build a balanced split with the matrix sharding guide.

Can I use the WarpBuild cache to skip package restore on every shard?

No. WarpBuild caches are not supported for Windows-based runners (cloud runners documentation). Windows shards use actions/cache for the NuGet package folder and any build output you want to carry between jobs, the same way they would on a GitHub-hosted Windows runner. The .NET test parallelism guide covers keying that cache off packages.lock.json so every shard restores from the same entry.

Start with $10 in free credits

Change the runner label in your workflow and keep the rest of your GitHub Actions setup. Runner time is billed per minute.