pytest-django icon indicating copy to clipboard operation
pytest-django copied to clipboard

Add `shared_db_wrapper` for creating long-lived db state

Open ktosiek opened this issue 10 years ago • 32 comments

This is my take on #105 and #243. This pull request adds a new fixture, shared_db_wrapper, that lets other fixtures access the database regardless of scope.

shared_db_wrapper is a contextmanager that creates a new savepoint/transaction, temporarily enables the cursor wrapper, and adds a finalizer through user fixture's request. This design, as opposed to making it a normal fixture, has 2 reasons:

  • finalization needs to be tied to user fixture's scope, not shared_db_wrapper's
  • I don't want just having some shared state loaded to leave the cursor enabled

Is this approach sensible? If so, I'll be happy to write the documentation and make sure this PR is mergeable.

TODO:

  • [ ] multiple databases (using=...)
  • [ ] make sure db runs after shared fixtures
  • [ ] use transaction.set_rollback(True, using=...) instead of dummy exception

ktosiek avatar Sep 04 '15 20:09 ktosiek

Looks good to me so far! Could you please squash it already?

Would this help for keeping data from migrations with transactional_db (https://github.com/pytest-dev/pytest-django/pull/220#issuecomment-83310828)? I was looking into https://github.com/fastmonkeys/stellar in this regard - which might be used if it becomes easily usable as a library (https://github.com/fastmonkeys/stellar/issues/53). It basically allows to create a snapshot and rollback to it, which could be used in transactional_db (or a new variant).

@pelme What do you think?

blueyed avatar Sep 15 '15 16:09 blueyed

Sure, I'll squash it shortly.

This pretty much bails out in transactional_db, so won't help there. But I think the API is restrictive enough on the user side that we can add some kind of snapshots later.

ktosiek avatar Sep 15 '15 18:09 ktosiek

Code Health Repository health decreased by 0.18% when pulling c2fd0cd on ktosiek:shared-db-setup into b9eb210 on pytest-dev:master.

landscape-bot avatar Sep 16 '15 05:09 landscape-bot

This is something I personally have been thinking about and wanting for a long time. The API and implementation looks good and simple to use! Passing request explicitly to tie the scope to the user fixture is the sensible (and possibly only way currently to handle this). This will be a killer feature for pytest-django! :)

Is session/module/class fixtures that is used at the same time handled properly? I see no reason why they shouldn't be, but it would be nice with a test that uses all scopes at the same time.

Transactional db support with stellar or flushes would be a really cool future addition to shared_db_wrapper.

pelme avatar Sep 28 '15 07:09 pelme

I can see how I could safely test mixing class and function scopes, but how would I write a test for using it with session and module scopes? Would a test that just calls the wrapper multiple times and checks the DB state around those calls good enough?

ktosiek avatar Sep 30 '15 16:09 ktosiek

@ktosiek It is possible to test that with a "pytester" test, that invokes a new pytest session. See for instance https://github.com/pytest-dev/pytest-django/blob/f1711f01c894682b69fed1924676916ecea53fb1/tests/test_unittest.py#L122-L153.

The trick is to use the django_testdir fixture which will create a new Django project for that particular test and then invoke django_testdir.runpytest() to run and collect tests.

pelme avatar Oct 04 '15 13:10 pelme

I'll push a pytester test shortly, but I also have some bad news: it looks like this won't work on Django 1.6 and 1.7, as they always close the DB connection in TransactionTestCase._post_teardown: https://github.com/django/django/blob/stable/1.7.x/django/test/testcases.py#L828

1.8 should be OK, as it only does that if some db backends don't support transactions.

ktosiek avatar Oct 04 '15 20:10 ktosiek

Code Health Repository health decreased by 0.14% when pulling b67b9ce on ktosiek:shared-db-setup into d3e03b9 on pytest-dev:master.

landscape-bot avatar Oct 04 '15 22:10 landscape-bot

Hmm, couldn't we monkey patch _post_teardown in those cases and have it not close the connection? It would for sure be nice to support Django 1.6 and 1.7 if possible!

pelme avatar Oct 05 '15 07:10 pelme

Django 1.6 and 1.7 don't receive security updates anymore. I don't think you should refrain from merging this feature just because it doesn't work on these versions. I suggest to skip the tests on Django < 1.8.

Is there anything I can do to help completing this feature?

aaugustin avatar Mar 16 '16 17:03 aaugustin

@aaugustin Agreed, it wouldn't be too bad to not support Django <1.7 with shared_db_wrapper, so let's avoid the complexities that would add.

With your expertise in Django transaction management, if you could have a quick look at the PR that would be very helpful. Also if you could try it out in a project where you need it that would be very helpful too.

There is nothing that blocks this from being merged. If I don't hear anything else I will merge it in a couple of days and push a new release. :)

pelme avatar Mar 16 '16 19:03 pelme

I'll try it in my current project and report back (hopefully today or tomorrow).

aaugustin avatar Mar 17 '16 09:03 aaugustin

I tried the patch and unfortunately I couldn't make it work.

I'm getting two kinds of errors:

  • some tests fail, apparently because of isolation issues between tests -- which indicates the database state isn't reset between tests like I expect
  • if the module where I declare a module-level shared_db_wrapper fixture also contains tests that don't touch the database and aren't marked with @pytest.mark.django_db, these tests fail with "Database access not allowed, use the "django_db" mark to enable it."

aaugustin avatar Mar 18 '16 09:03 aaugustin

I'm not comfortable with pytest's internal APIs, but the general scheme here should be:

    with transaction.atomic():
        # run shared_db_wrapper fixtures
        for test in in tests:
            with transaction.atomic():
                # run test
                transaction.set_rollback(True)
        transaction.set_rollback(True)

I hope this helps...

aaugustin avatar Mar 18 '16 09:03 aaugustin

If there's issues with this, I would be glad to help out. Let me know what's still missing.

davidhalter avatar Mar 27 '16 14:03 davidhalter

This still has problems: there's no way that I see to force py.test to only run db after shared state fixtures. This can cause the db atomic to be started first (it should be last, or at least after all shared fixtures), and then the whole test will get run in the last shared fixture's atomic (and will rollback the last fixture's DB changes).

It looks like this behavior depends on order of fixtures in first test function, maybe we can fix it by hooking into test collection?

ktosiek avatar Mar 29 '16 21:03 ktosiek

This still has problems: there's no way that I see to force py.test to only run db after shared state fixtures.

Would it help to use the decorator approach you proposed above? I think this would make sense even just for usability. I don't like how complicated it is, currently.

davidhalter avatar Mar 29 '16 22:03 davidhalter

Not really, the decorator would just hide the with shared_db_wrapper(...): part, and won't change how it all works. But maybe a pytest_runtest_setup hook could be used to order test's fixtures by scope.

ktosiek avatar Mar 29 '16 22:03 ktosiek

But with a decorator you could control the fixture order, right? You could just place it in the first spot. (Note that I don't have a lot of knowledge about py.test fixtures)

davidhalter avatar Mar 30 '16 13:03 davidhalter

Nope, what I need is to affect relative ordering between fixtures on the actual test. It's not about fixture's dependencies. Using a decorator won't help there.

I kind of have an idea how to do that (which is a bit hacky - using pytest_runtest_setup and pre-loading some fixtures with item._request.getargvalue), but I'll have to try it (hopefully later today).

ktosiek avatar Mar 30 '16 16:03 ktosiek

Hmm, I see! There's probably solutions for that as well, but they are quite hacky. Wouldn't this work?:

import inspect                                                                  
from textwrap import dedent                                                     
from functools import wraps                                                     


def shared_db_fixture(scope='module'):                                          
    def func_wrapper(func):                                                     
        arg_list = list(inspect.getfullargspec(func))                           
        old_args = inspect.formatargspec(*arg_list)                             
        arg_list[0].insert(0, 'request')                                        
        arg_list[0].insert(1, '_shared_db_wrapper')                             
        new_args = inspect.formatargspec(*arg_list)                             

        code = dedent("""                                                       
        @wraps(func)                                                            
        def wrapper%s:                                                          
            with _shared_db_wrapper(request):                                   
                return func%s                                                   
        """ % (new_args, old_args))                                             

        exec(code, globals(), locals())                                         
        print(code, locals())                                                   
        return locals()['wrapper']  # Don't ask me why it doesn't work directly.

    return func_wrapper                                                         


@shared_db_fixture()                                                            
def test_hello(my_fixture):                                                     
    assert myfixture.foobar                                                     

Clearly this is hacky, but it's actually very clear what it does. Hacking around with pytest_runtest_setup is probably not cleaner (and if so, please show me :)). Also, pytest itself has very hacky origins, since it uses function names as an actual feature (which is strange, when you first start with pytest, but really cool once you get it). This leads to the fact that there's no way of modiying the function input anymore, like args/kwargs in a decorator, except for exec.

Tell me what you think!

PS: This solution has a few issues when it comes to Python 2 support and certain signatures, but they are all solvable.

davidhalter avatar Mar 30 '16 20:03 davidhalter

as a nicer looking API? Probably. But it won't help with the fixture instantiation order (you'd need to wrap each test to move db-based fixtures after shared db fixtures)

ktosiek avatar Mar 31 '16 06:03 ktosiek

True. I've just realized how complicated this actually is.

davidhalter avatar Mar 31 '16 08:03 davidhalter

/cc @nicoddemus 😅

pelme avatar Jun 22 '16 09:06 pelme

My intermediate fix BTW is to use this:

@pytest.fixture(scope="session", autouse=True)
def setup_django_db_hack(_django_db_setup, request, _django_cursor_wrapper):
    _django_db_fixture_helper(False, request, _django_cursor_wrapper)

This initializes the database in the beginning and with that all fixtures can write to it. What do you guys think about this?

davidhalter avatar Jun 22 '16 15:06 davidhalter

Even though this is quite an old PR, I would be very much interested in the functionality it provides (without reading through the whole thread though). Why hasn't it been merged? Just because of the merge conflicts?

larsrinn avatar Feb 13 '18 20:02 larsrinn

IMO it's not just a simple problem and this PR doesn't solve everything, really. There are issues in the underlying pytest infrastructure/architecture that make it really hard to do this right.

My personal approach has been altered a bit, I use this for a kind of large Django project:

@pytest.fixture(scope="session", autouse=True)
def actually_setup_db(django_db_setup):
    """
    This is an autouse fixture to guarantee calling django_db_setup. If this is
    not done, Django allows accessing the sqlite.db file and modifying it. This
    is obviously NOT what we want.
    """


@pytest.fixture(scope="session")
def session_db(actually_setup_db, django_db_blocker):
    """
    Unlocks the database session-wide. The default of django is to block the
    database.
    """
    with django_db_blocker.unblock():
        yield

It's not perfect. There are some major issues that you might run into. Don't ask me why I didn't make this an autouse fixture... It's just bug-prone and you have to play with it. The fixtures also might need to be defined in the right places, etc. It's just not what you really want. They need to improve some pytest things first, IMO.

I also think that this issue might be a blocker and is highly relevant: https://github.com/pytest-dev/pytest/issues/2405

davidhalter avatar Feb 13 '18 23:02 davidhalter

IIRC this PR was blocked by problems with forcing fixture ordering (see https://github.com/pytest-dev/pytest-django/pull/258#issuecomment-203130289), I haven't checked if there are any new options for working around them.

ktosiek avatar Feb 14 '18 05:02 ktosiek

@ktosiek Looks like fixtures are now loaded in scope order, thanks to https://github.com/pytest-dev/pytest/pull/3306 (which went into pytest 3.5.0). Does this help things?

yozlet avatar Aug 23 '18 22:08 yozlet

@yozlet thank you for pointing this out, it might help. But I won't have time to work on this PR this month.

ktosiek avatar Aug 24 '18 04:08 ktosiek