Skip to content

Quadratic runtime - O(n*n), n = number of tests #13360

@fatso83

Description

@fatso83

When doing some changes to a test report generation plugin, I was generating tests of various kinds from a script and ran it through pytest. I noticed that the runtime was non-linear. When the number of failing tests were close to 0, a couple of thousand tests were fine (<5 sec). If I on the other hand had about 10% failing tests, the runtime would be near half a minute, and after some experimenting I could see the runtime increased exponentially. Given that we have quite a few failing tests at times (and several thousand tests), this was quite noticeable.

Image

I also ruled out that this was a function of the number of failing tests, as keeping the total the same, but doubling the percentage only shows a linear graph:

Image

I removed the content of the conftest.py, so I think the below should be a pretty good way of recreating it.

  • a detailed description of the bug or problem you are having

  • output of pip list from the virtual environment you are using

  • pytest and operating system versions

  • minimal example if possible

  • Pytest 8.3.4

  • Ubuntu 24.04.2 LTS (on WSL2 Windows 11)

pip list
attrs                 25.1.0
certifi               2025.1.31
charset-normalizer    3.4.1
grpcio                1.70.0
grpcio-tools          1.70.0
idna                  3.10
iniconfig             2.0.0
isodate               0.7.2
line-profiler         4.2.0
lxml                  5.3.1
markdown              3.7
nodeenv               1.9.1
nodejs-wheel-binaries 22.14.0
packaging             24.2
platformdirs          4.3.6
pluggy                1.5.0
protobuf              5.29.3
pyright               1.1.394
pytest                8.3.4
pytz                  2025.1
requests              2.32.3
requests-file         2.1.0
requests-toolbelt     1.0.0
ruff                  0.9.7
setuptools            75.8.0
typing-extensions     4.12.2
urllib3               2.3.0
zeep                  4.3.1

Minimal example

run.sh

Run like FAILING=10 TOTAL=1600 ./run.sh (replace TOTAL with 200,400,600,800,1000,1200, ...)

#!/bin/bash
./generate_tests.py --failing ${FAILING:-10} --total ${TOTAL:-200} --filename generated_test.py
pytest ./generated_tests.py

generate_tests.py

#!/usr/bin/env python3

import argparse
import random
import re


def generate_test_case(name: str, should_fail: bool, marks: list[str]) -> str:
    decorators = "".join([f"@pytest.mark.{mark}\n" for mark in marks])
    if should_fail:
        return f"{decorators}def test_{name}():\n    pytest.fail(\"bogus reason\")\n"
    else:
        return f"{decorators}def test_{name}():\n    pass\n"


def parse_mark_args(mark_arg: str, total: int) -> dict:
    mark_distribution = {}
    if not mark_arg:
        return mark_distribution
    pattern = re.compile(r"(\w+):(\d+)")
    for match in mark_arg.split(','):
        m = pattern.match(match.strip())
        if m:
            mark = m.group(1)
            percentage = int(m.group(2))
            count = round(total * (percentage / 100))
            mark_distribution[mark] = count
    return mark_distribution


def assign_marks(total: int, mark_distribution: dict) -> list[list[str]]:
    mark_lists = [[] for _ in range(total)]
    for mark, count in mark_distribution.items():
        indices = random.sample(range(total), count)
        for idx in indices:
            mark_lists[idx].append(mark)
    return mark_lists


def main():
    parser = argparse.ArgumentParser(description="Generate bogus pytest test cases.")
    parser.add_argument("--failing", type=int, default=30, help="Percentage of tests that should fail (default 30%)")
    parser.add_argument("--total", type=int, required=True, help="Total number of test cases")
    parser.add_argument("--filename", type=str, required=True, help="Output filename for test cases")
    parser.add_argument("--mark", type=str, help="Comma-separated list of marks and percentages, e.g. foo:20,bar:10")

    args = parser.parse_args()

    total = args.total
    failing_percentage = args.failing
    failing_count = round(total * (failing_percentage / 100))
    passing_count = total - failing_count

    test_cases = [True] * failing_count + [False] * passing_count
    random.shuffle(test_cases)

    mark_distribution = parse_mark_args(args.mark, total)
    mark_lists = assign_marks(total, mark_distribution)

    print("Generated test suite with bogus test cases")

    with open(args.filename, "w") as f:
        f.write("\nimport pytest\n\n")
        for i, should_fail in enumerate(test_cases):
            marks = mark_lists[i]
            test_code = generate_test_case(f"case_{i+1}", should_fail, marks)
            f.write(test_code + "\n")


if __name__ == "__main__":
    main()

Metadata

Metadata

Assignees

No one assigned

    Labels

    type: performanceperformance or memory problem/improvement

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions