blob: eed724da299c3a7893938ddce9e79147633c40be [file]
# Copyright 2026 The Fuchsia Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Minimal thread-pool unittest runner for parallel host test execution."""
from __future__ import annotations
import concurrent.futures
import os
import sys
import threading
import unittest
from collections.abc import Iterator
from types import TracebackType
from typing import Any
_SysExcInfoType = (
tuple[type[BaseException], BaseException, TracebackType | None]
| tuple[None, None, None]
)
DEFAULT_MAX_WORKERS = 4
def _iter_cases(
suite: unittest.TestSuite | unittest.TestCase,
) -> Iterator[unittest.TestCase]:
if isinstance(suite, unittest.TestCase):
yield suite
else:
for item in suite:
yield from _iter_cases(item)
class _CaseResult(unittest.TestResult):
"""Per-test result recorder that buffers events for thread-safe replay."""
def __init__(self) -> None:
self.events: list[tuple[str, tuple[Any, ...]]] = []
super().__init__()
def addSuccess(self, test: unittest.TestCase) -> None:
self.events.append(("addSuccess", (test,)))
def addError(self, test: unittest.TestCase, err: _SysExcInfoType) -> None:
self.events.append(("addError", (test, err)))
def addFailure(self, test: unittest.TestCase, err: _SysExcInfoType) -> None:
self.events.append(("addFailure", (test, err)))
def addSkip(self, test: unittest.TestCase, reason: str) -> None:
self.events.append(("addSkip", (test, reason)))
def addExpectedFailure(
self, test: unittest.TestCase, err: _SysExcInfoType
) -> None:
self.events.append(("addExpectedFailure", (test, err)))
def addUnexpectedSuccess(self, test: unittest.TestCase) -> None:
self.events.append(("addUnexpectedSuccess", (test,)))
def addSubTest(
self,
test: unittest.TestCase,
subtest: unittest.TestCase,
err: _SysExcInfoType | None,
) -> None:
self.events.append(("addSubTest", (test, subtest, err)))
def _run_case(
case: unittest.TestCase,
result: unittest.TestResult,
lock: threading.Lock,
) -> None:
case_result = _CaseResult()
case.run(case_result)
with lock:
result.startTest(case)
for method_name, args in case_result.events:
getattr(result, method_name)(*args)
result.stopTest(case)
class _ParallelSuite(unittest.TestSuite):
"""TestSuite wrapper that executes leaf TestCases across a thread pool."""
def __init__(
self,
suite: unittest.TestSuite | unittest.TestCase,
max_workers: int,
) -> None:
super().__init__()
self._root = suite
self._max_workers = max_workers
def run(
self, result: unittest.TestResult, debug: bool = False
) -> unittest.TestResult:
if debug:
self._root.run(result)
return result
cases_by_class: dict[
type[unittest.TestCase], list[unittest.TestCase]
] = {}
for case in _iter_cases(self._root):
cases_by_class.setdefault(type(case), []).append(case)
lock = threading.Lock()
active_classes: list[
tuple[type[unittest.TestCase], list[unittest.TestCase]]
] = []
runnable_cases: list[unittest.TestCase] = []
for cls, cases in cases_by_class.items():
try:
cls.setUpClass()
except Exception:
result.startTest(cases[0])
result.addError(cases[0], sys.exc_info())
result.stopTest(cases[0])
cls.doClassCleanups()
else:
active_classes.append((cls, cases))
runnable_cases.extend(cases)
try:
if runnable_cases:
with concurrent.futures.ThreadPoolExecutor(
max_workers=self._max_workers
) as executor:
futures = [
executor.submit(_run_case, case, result, lock)
for case in runnable_cases
]
for future in futures:
future.result()
finally:
for cls, cases in active_classes:
try:
cls.tearDownClass()
except Exception:
with lock:
result.addError(cases[0], sys.exc_info())
finally:
cls.doClassCleanups()
return result
class ParallelTextTestRunner(unittest.TextTestRunner):
"""TextTestRunner that runs test cases concurrently in worker threads."""
def __init__(
self,
*args: Any,
max_workers: int | None = None,
**kwargs: Any,
) -> None:
super().__init__(*args, **kwargs)
if max_workers is None:
env_jobs = os.environ.get("AGENT_TEST_JOBS", "")
max_workers = (
int(env_jobs) if env_jobs.isdigit() else DEFAULT_MAX_WORKERS
)
self.max_workers = max(1, max_workers)
def run(
self, test: unittest.TestSuite | unittest.TestCase
) -> unittest.TextTestResult[Any]:
"""Run the given test case or test suite."""
if self.max_workers <= 1:
return super().run(test)
return super().run(_ParallelSuite(test, self.max_workers))
def parallel_main() -> None:
"""Run unittest.main() using ParallelTextTestRunner."""
unittest.main(testRunner=ParallelTextTestRunner)