Skip to content

Conversation

saltie2193
Copy link
Contributor

Improve performance of backend tests by patching password hashing during tests.

NOTE: To reduce interference with preexisting data, all existing users and items are now deleted before the tests are run.
This should not be noticable outside of testing, since all users and items already have been deleted after testing anyway.

@saltie2193 saltie2193 force-pushed the backend-tests-performance-improvements branch 2 times, most recently from 85712f8 to 05710bd Compare October 12, 2024 19:04
@alejsdev alejsdev changed the title Improve backend test performance by patching password hashing ⚡️ Improve backend test performance by patching password hashing Oct 24, 2024
@saltie2193 saltie2193 force-pushed the backend-tests-performance-improvements branch from 05710bd to 5fe9712 Compare January 20, 2025 17:00
@mdrxy
Copy link

mdrxy commented Mar 30, 2025

This substantially reduces testing time - thank you!

Only thing I noticed is that session.execute should become session.exec due to deprecation.

@saltie2193
Copy link
Contributor Author

saltie2193 commented Apr 1, 2025

At least according to mypy, session.exec of a SQLModel Session does not have an overload that would allow for passing a delete statement. Using it will fail the linting.
However it seems to work anyway.

mypy output when using session.exec:

app/tests/conftest.py:28: error: No overload variant of "exec" of "Session" matches argument type "Delete"  [call-overload]
app/tests/conftest.py:28: note: Possible overload variants:
app/tests/conftest.py:28: note:     def [_TSelectParam] exec(self, statement: Select[_TSelectParam], *, params: Mapping[str, Any] | Sequence[Mapping[str, Any]] | None = ..., execution_options: Mapping[str, Any] = ..., bind_arguments: dict[str, Any] | None = ..., _parent_execute_state: Any | None = ..., _add_event: Any | None = ...) -> TupleResult[_TSelectParam]
app/tests/conftest.py:28: note:     def [_TSelectParam] exec(self, statement: SelectOfScalar[_TSelectParam], *, params: Mapping[str, Any] | Sequence[Mapping[str, Any]] | None = ..., execution_options: Mapping[str, Any] = ..., bind_arguments: dict[str, Any] | None = ..., _parent_execute_state: Any | None = ..., _add_event: Any | None = ...) -> ScalarResult[_TSelectParam]
app/tests/conftest.py:29: error: No overload variant of "exec" of "Session" matches argument type "Delete"  [call-overload]
app/tests/conftest.py:29: note: Possible overload variants:
app/tests/conftest.py:29: note:     def [_TSelectParam] exec(self, statement: Select[_TSelectParam], *, params: Mapping[str, Any] | Sequence[Mapping[str, Any]] | None = ..., execution_options: Mapping[str, Any] = ..., bind_arguments: dict[str, Any] | None = ..., _parent_execute_state: Any | None = ..., _add_event: Any | None = ...) -> TupleResult[_TSelectParam]
app/tests/conftest.py:29: note:     def [_TSelectParam] exec(self, statement: SelectOfScalar[_TSelectParam], *, params: Mapping[str, Any] | Sequence[Mapping[str, Any]] | None = ..., execution_options: Mapping[str, Any] = ..., bind_arguments: dict[str, Any] | None = ..., _parent_execute_state: Any | None = ..., _add_event: Any | None = ...) -> ScalarResult[_TSelectParam]
Found 2 errors in 1 file (checked 38 source files)

I'm not sure what's the better option here. Do we ignore the type checker and provided signature for session.exec? Or, do we insist on not using deprecated methods were it seems like there is no replacement?

Copy link
Contributor

@YuriiMotov YuriiMotov left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@saltie2193, good idea!

In addition to the comment below, I think we should leave 1 test that would be run without this patching to ensure real algorithm works (mark test with @pytest.mark.noautofixt and pass db fixture explicitly).

@pytest.fixture(scope="session", autouse=True)
def db() -> Generator[Session, None, None]:
def db(
disable_password_hashing: Generator[None, None, None], # noqa: ARG001
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we shouldn't mix disable_password_hashing with db fixture

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In this case should we make disable_password_hashing an autouse fixture and just explicitly disable the fixture in tests where we want hashing to be enabled?

I think this would still be necessary since I would assume we want hashing disabled for almost all tests and only enable it for a few specific ones.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After some investigation the disable_password_hashing and db fixtures were mixed because of the call to init_db in the db fixture. When password hashing is not enabled during this setup, the database is initialized with a hashed password of the superuser, while the tests try to authenticate the user without hashing the provided password.
This could be mitigated if tests would not rely on an initial setup of the database and the configured superuser, but setup up (and tear down) the required test data on their own. I don't think this easily fixable here, since we would need to remove the dependency of the tests to an initial setup.

For the time being while this interdependency between test exists, a workaround or solution could be to create a new module testing authentication with hashing enabled and create overrides for db and client with enabled password hashing, recreating the database with hashed passwords. In this case the default db and client fixtures would stay mixed with the disable_password_hashing fixture, since we want to disable it by default in almost every test case and it is required due to the dependency on the initial setup.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure I understand the problem..
What if we create init_db_fixtrue fixture:

@pytest.fixture(scope="session", autouse=True)
def init_db_fixture(db: Session) -> Generator[None, None, None]:
    init_db(db)
    yield

.. and remove the init_db call from db fixture?

The main question is: if the order of fixtures in conftest.py defines the order they will be applied automatically?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The problem is the password patching needs to be applied, before the init_db_fixture is executed. Every time we want to disable or enable it, the database needs to be initialized again.

As far as I understand the pytest documentation, only factors are

  1. scope,
  2. dependencies, or
  3. autouse.

For the time being it seems to work, but the dependency of tests to an initial setup is still a problem. The current compromise is to introduce a marker to enable password hashing on a module level. So the initial setup is only run before every module. In the long term I think it would be best to remove this dependency allowing to enable or disable hashing per test. This would remove the problem of execution order of the fixtures as well.

- Add marker to activate password hashing on module level.
- Change scope of `db` and `client` fixtures to `module`.
- Add test without patched password hashing
@saltie2193 saltie2193 force-pushed the backend-tests-performance-improvements branch from 5874ecf to 5e2bdf0 Compare September 17, 2025 12:42
@github-actions github-actions bot removed the waiting label Sep 17, 2025
@YuriiMotov
Copy link
Contributor

YuriiMotov commented Sep 18, 2025

Sorry for pushing to your branch, it was simpler than explaining this in review..
If you don't like changes, feel free to revert commits or use only part of suggested changes.

I suggest simplify tests:

  • Since we only patch one module, we don't need that complex things with ExitStack.
  • Added if..else condition in disable_password_hashing to make logic clearer
  • Explicitly added disable_password_hashing dependency to db fixture
  • Removed init_db_fixture (moved init logic to db fixture)
  • Simplified docstrings and comments (IMO, some of them were too verbose, some were incorrect)
  • Removed unnecessary second test from test_login_with_hashing.py

Copy link
Contributor

This pull request has a merge conflict that needs to be resolved.

@github-actions github-actions bot added the conflicts Automatically generated when a PR has a merge conflict label Sep 20, 2025
@github-actions github-actions bot removed the conflicts Automatically generated when a PR has a merge conflict label Sep 24, 2025
@saltie2193
Copy link
Contributor Author

That's basically the original proposal now so looks fine to me.

I hope it's fine I reverted the changes to keep the patch_passwors_hashing utility. I think this way it's a bit cleaner and it can be reused easily. If you feel strong about not having it feel free to remove it again.

@github-actions github-actions bot removed the waiting label Sep 24, 2025
Copy link
Contributor

@YuriiMotov YuriiMotov left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!

This disables password hashing for all tests except one (in test_login_with_hashing.py - added to make sure login functionality still works with password hashing).

I still think it would be simpler to just use two patches instead of implementing patch_password_hashing with ExitStack, but let's Sebastian decide. I would revert this commit

@saltie2193, thank you for working on this!

Comment on lines +35 to +37
Contextmanager to patch ``pwd_context`` in the given modules.
:param modules: list of modules to patch.
:return:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Contextmanager to patch ``pwd_context`` in the given modules.
:param modules: list of modules to patch.
:return:
Contextmanager to patch `pwd_context` in the given modules.

We don't use this style for docstrings in this project

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants