├── .coveragerc ├── .github └── workflows │ └── main.yml ├── .gitignore ├── LICENSE ├── MANIFEST.in ├── README.rst ├── setup.cfg ├── setup.py ├── testdata ├── __init__.py ├── decorators.py └── descriptors.py ├── tests ├── __init__.py ├── models.py ├── settings.py ├── test_django_32.py └── tests.py └── tox.ini /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | source = testdata 3 | branch = 1 4 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | 9 | jobs: 10 | tests: 11 | name: Python ${{ matrix.python-version }} 12 | runs-on: ubuntu-18.04 13 | 14 | strategy: 15 | matrix: 16 | python-version: 17 | - 2.7 18 | - 3.5 19 | - 3.6 20 | - 3.7 21 | - 3.8 22 | - 3.9 23 | 24 | steps: 25 | - uses: actions/checkout@v2 26 | - uses: actions/setup-python@v2 27 | with: 28 | python-version: ${{ matrix.python-version }} 29 | - uses: actions/cache@v2 30 | with: 31 | path: ~/.cache/pip 32 | key: ${{ runner.os }}-pip-${{ hashFiles('requirements/*.txt') }} 33 | restore-keys: | 34 | ${{ runner.os }}-pip- 35 | - name: Upgrade packaging tools 36 | run: python -m pip install --upgrade pip setuptools virtualenv 37 | - name: Install dependencies 38 | run: python -m pip install --upgrade tox 39 | - name: Run tox targets for ${{ matrix.python-version }} 40 | run: | 41 | ENV_PREFIX=$(tr -C -d "0-9" <<< "${{ matrix.python-version }}") 42 | TOXENV=$(tox --listenvs | grep "^py$ENV_PREFIX" | tr '\n' ',') python -m tox 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | syntax:glob 2 | *.py[co] 3 | dist/ 4 | django_testdata.egg-info/* 5 | build/ 6 | .coverage 7 | .tox 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018, Simon Charette 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.rst -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | django-testdata 2 | =============== 3 | 4 | .. image:: https://img.shields.io/pypi/l/django-testdata.svg?style=flat 5 | :target: https://pypi.python.org/pypi/django-testdata/ 6 | :alt: License 7 | 8 | .. image:: https://img.shields.io/pypi/v/django-testdata.svg?style=flat 9 | :target: https://pypi.python.org/pypi/django-testdata/ 10 | :alt: Latest Version 11 | 12 | .. image:: https://img.shields.io/github/workflow/status/charettes/django-testdata/CI/master 13 | :target: https://github.com/charettes/django-testdata/actions?workflow=CI 14 | 15 | .. image:: https://coveralls.io/repos/charettes/django-testdata/badge.svg?branch=master 16 | :target: https://coveralls.io/r/charettes/django-testdata?branch=master 17 | :alt: Coverage Status 18 | 19 | .. image:: https://img.shields.io/pypi/pyversions/django-testdata.svg?style=flat 20 | :target: https://pypi.python.org/pypi/django-testdata/ 21 | :alt: Supported Python Versions 22 | 23 | .. image:: https://img.shields.io/pypi/wheel/django-testdata.svg?style=flat 24 | :target: https://pypi.python.org/pypi/django-testdata/ 25 | :alt: Wheel Status 26 | 27 | Django application providing isolation for model instances created during 28 | `setUpTestData`. 29 | 30 | **Note:** This package has been merged into Django and released in version 31 | 3.2 (see `PR #12608 `__). 32 | 33 | Installation 34 | ------------ 35 | 36 | .. code:: sh 37 | 38 | pip install django-testdata 39 | 40 | Motivation 41 | ---------- 42 | 43 | Django 1.8 introduced ``TestCase.setUpTestData`` to allow costly generation of 44 | model fixtures to be executed only once per test class in order to speed up 45 | testcase instances execution. 46 | 47 | One gotcha of ``setUpTestData`` though is that test instances all share the same 48 | model instances and have to be careful not to alter them to prevent breaking 49 | test isolation. Per Django's `documentation`_:: 50 | 51 | Be careful not to modify any objects created in setUpTestData() in your 52 | test methods. Modifications to in-memory objects from setup work done at 53 | the class level will persist between test methods. If you do need to modify 54 | them, you could reload them in the setUp() method with refresh_from_db(), 55 | for example. 56 | 57 | Reloading objects in ``setUp()`` certainly works but it kind of defeats the 58 | purpose of avoiding database hits to speed up tests execution in the first 59 | place. It makes little sense to fetch model instances from the database 60 | given all the data is available in memory. 61 | 62 | This package offers a different alternative to work around this quirk of 63 | ``setUpTestData``. Instead of reloading objects from the database the model 64 | instances assigned as class attributes during ``setUpTestData`` are lazily deep 65 | copied on test case instance accesses. All deep copying during a test is done 66 | with a shared `memo`_ which makes sure in-memory relationships between objects 67 | are preserved. 68 | 69 | .. _documentation: https://docs.djangoproject.com/en/2.1/topics/testing/tools/#django.test.TestCase.setUpTestData 70 | .. _memo: https://docs.python.org/3/library/copy.html?highlight=memo#copy.deepcopy 71 | 72 | Usage 73 | ----- 74 | 75 | The test data can be either wrapped manually by using ``testdata``: 76 | 77 | .. code:: python 78 | 79 | from django.test import TestCase 80 | from testdata import testdata 81 | 82 | from .models import Author, Book 83 | 84 | class BookTests(TestCase): 85 | @classmethod 86 | def setUpTestData(cls): 87 | cls.author = testdata(Author.objects.create( 88 | name='Milan Kundera', 89 | )) 90 | cls.book = testdata(cls.author.books.create( 91 | title='Nesnesitelná lehkost bytí', 92 | )) 93 | 94 | Or automatically by using the ``wrap_testdata`` decorator: 95 | 96 | .. code:: python 97 | 98 | from django.test import TestCase 99 | from testdata import wrap_testdata 100 | 101 | from .models import Author, Book 102 | 103 | class BookTests(TestCase): 104 | @classmethod 105 | @wrap_testdata 106 | def setUpTestData(cls): 107 | cls.author = Author.objects.create( 108 | name='Milan Kundera', 109 | ) 110 | cls.book = cls.author.books.create( 111 | title='Nesnesitelná lehkost bytí', 112 | ) 113 | 114 | Under the hood ``wrap_testdata`` wraps all attributes added to ``cls`` 115 | during the execution of ``setUpTestData()`` into ``testdata(attr, name=name)``. 116 | 117 | Once test data is wrapped the testcase instance methods can alter objects 118 | retrieved from ``self`` without worrying about cross-tests isolation: 119 | 120 | .. code:: python 121 | 122 | from django.test import TestCase 123 | from testdata import wrap_testdata 124 | 125 | from .models import Author, Book 126 | 127 | class BookTests(TestCase): 128 | @classmethod 129 | @wrap_testdata 130 | def setUpTestData(cls): 131 | cls.author = Author.objects.create( 132 | name='Milan Kundera', 133 | ) 134 | cls.book = cls.author.books.create( 135 | title='Nesnesitelná lehkost bytí', 136 | ) 137 | 138 | def test_book_name_english(self): 139 | self.assertEqual(self.book.title, 'Nesnesitelná lehkost bytí') 140 | self.book.title = 'The Unbearable Lightness of Being' 141 | self.book.save() 142 | 143 | def test_book_name_french(self): 144 | self.assertEqual(self.book.title, 'Nesnesitelná lehkost bytí') 145 | self.book.title = "L'Insoutenable Légèreté de l'être" 146 | self.book.save() 147 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 119 3 | 4 | [metadata] 5 | license-file = LICENSE 6 | 7 | [bdist_wheel] 8 | universal = 1 9 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | from setuptools import find_packages, setup 4 | 5 | import testdata 6 | 7 | with open("README.rst") as file_: 8 | long_description = file_.read() 9 | 10 | setup( 11 | name="django-testdata", 12 | version=testdata.__version__, 13 | description="Django application providing isolation for model instances created during `setUpTestData`.", 14 | long_description=long_description, 15 | long_description_content_type="text/rst", 16 | url="https://github.com/charettes/django-testdata", 17 | author="Simon Charette.", 18 | author_email="charette.s+testdata@gmail.com", 19 | license="MIT", 20 | classifiers=[ 21 | "Development Status :: 7 - Inactive", 22 | "Environment :: Web Environment", 23 | "Framework :: Django", 24 | "Framework :: Django :: 1.11", 25 | "Framework :: Django :: 2.1", 26 | "Framework :: Django :: 2.2", 27 | "Framework :: Django :: 3.0", 28 | "Framework :: Django :: 3.1", 29 | "Intended Audience :: Developers", 30 | "License :: OSI Approved :: MIT License", 31 | "Operating System :: OS Independent", 32 | "Programming Language :: Python", 33 | "Programming Language :: Python :: 2", 34 | "Programming Language :: Python :: 2.7", 35 | "Programming Language :: Python :: 3", 36 | "Programming Language :: Python :: 3.5", 37 | "Programming Language :: Python :: 3.6", 38 | "Programming Language :: Python :: 3.7", 39 | "Programming Language :: Python :: 3.8", 40 | "Programming Language :: Python :: 3.9", 41 | "Topic :: Software Development :: Libraries :: Application Frameworks", 42 | "Topic :: Software Development :: Libraries :: Python Modules", 43 | ], 44 | keywords=["django test testdata"], 45 | packages=find_packages(exclude=["tests", "tests.*"]), 46 | install_requires=["Django>=1.11"], 47 | extras_require={ 48 | "tests": ["tox"], 49 | }, 50 | ) 51 | -------------------------------------------------------------------------------- /testdata/__init__.py: -------------------------------------------------------------------------------- 1 | from .descriptors import testdata 2 | from .decorators import wrap_testdata 3 | 4 | import django.utils.version 5 | 6 | __all__ = ['VERSION', '__version__', 'testdata', 'wrap_testdata'] 7 | 8 | VERSION = (1, 0, 3, 'final', 0) 9 | 10 | __version__ = django.utils.version.get_version(VERSION) 11 | -------------------------------------------------------------------------------- /testdata/decorators.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | from functools import wraps 4 | 5 | import django 6 | 7 | from .descriptors import testdata 8 | 9 | 10 | def wrap_testdata(setup): 11 | """ 12 | A setUpTestData decorator that wraps every class attribute assignment 13 | during the execution of its decorated method into testdata instances. 14 | """ 15 | if django.VERSION >= (3, 2): 16 | raise TypeError( 17 | "django-testdata should not be used on Django 3.2, since it has " 18 | + "been merged into Django core." 19 | ) 20 | 21 | @wraps(setup) 22 | def inner(cls): 23 | pre_attrs = set(cls.__dict__) 24 | setup(cls) 25 | added_attrs = set(cls.__dict__) - pre_attrs 26 | for attr in added_attrs: 27 | value = getattr(cls, attr) 28 | setattr(cls, attr, testdata(value, attr)) 29 | return inner 30 | -------------------------------------------------------------------------------- /testdata/descriptors.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | import copy 4 | 5 | 6 | class testdata(object): 7 | """ 8 | Descriptor meant to provide TestCase instance isolation for attributes 9 | assigned during the setUpTestData phase. 10 | 11 | It allows the manipulation of objects assigned in setUpTestData by test 12 | methods without having to ensure the data is refetched from the database 13 | """ 14 | memo_attr = '_testdata_memo' 15 | 16 | def __init__(self, data, name=None): 17 | self.data = data 18 | self.name = name 19 | 20 | def get_memo(self, testcase): 21 | try: 22 | memo = getattr(testcase, self.memo_attr) 23 | except AttributeError: 24 | memo = {} 25 | setattr(testcase, self.memo_attr, memo) 26 | testcase.addCleanup(delattr, testcase, self.memo_attr) 27 | return memo 28 | 29 | def __get__(self, instance, owner): 30 | if instance is None: 31 | return self.data 32 | 33 | memo = self.get_memo(instance) 34 | try: 35 | data = copy.deepcopy(self.data, memo) 36 | except TypeError: 37 | if self.name is not None: 38 | raise TypeError( 39 | "%s.%s.%s must be deepcopy'able to be wrapped in testdata." % ( 40 | owner.__module__, 41 | owner.__name__, 42 | self.name, 43 | ) 44 | ) 45 | raise TypeError( 46 | "%r must be deepcopy'able to be wrapped in testdata." % self.data 47 | ) 48 | if self.name is not None: 49 | setattr(instance, self.name, data) 50 | # Avoid keeping a reference to cached attributes on teardown as it 51 | # prevents garbage collection of objects until the suite collected. 52 | instance.addCleanup(delattr, instance, self.name) 53 | return data 54 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/charettes/django-testdata/1a524b39dbfc2a06eebc10b945795dfd681592f8/tests/__init__.py -------------------------------------------------------------------------------- /tests/models.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | from django.db import models 4 | 5 | try: 6 | from django.utils.encoding import python_2_unicode_compatible 7 | except ImportError: 8 | def python_2_unicode_compatible(cls): 9 | return cls 10 | 11 | 12 | @python_2_unicode_compatible 13 | class Author(models.Model): 14 | name = models.CharField(max_length=100) 15 | 16 | def __str__(self): 17 | return self.name 18 | 19 | 20 | @python_2_unicode_compatible 21 | class Book(models.Model): 22 | title = models.CharField(max_length=100) 23 | author = models.ForeignKey(Author, models.CASCADE, related_name='books') 24 | 25 | def __str__(self): 26 | return self.title 27 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | SECRET_KEY = 'not-anymore' 4 | 5 | TIME_ZONE = 'America/Chicago' 6 | 7 | DATABASES = { 8 | 'default': { 9 | 'ENGINE': 'django.db.backends.sqlite3', 10 | } 11 | } 12 | 13 | INSTALLED_APPS = [ 14 | 'tests', 15 | ] 16 | 17 | SILENCED_SYSTEM_CHECKS = [] 18 | -------------------------------------------------------------------------------- /tests/test_django_32.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from unittest import skipIf 5 | 6 | import django 7 | from django.test import SimpleTestCase 8 | 9 | from testdata import wrap_testdata 10 | 11 | 12 | @skipIf(django.VERSION < (3, 2), "Django 3.2 only tests.") 13 | class WrapTestDataTests(SimpleTestCase): 14 | def test_raises_exception_on_use(self): 15 | msg = ( 16 | "django-testdata should not be used on Django 3.2, since it has " 17 | + "been merged into Django core." 18 | ) 19 | with self.assertRaisesMessage(TypeError, msg): 20 | @wrap_testdata 21 | def example(): 22 | pass 23 | -------------------------------------------------------------------------------- /tests/tests.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from functools import wraps 5 | from unittest import SkipTest 6 | 7 | import django 8 | from django.test import TestCase 9 | 10 | from testdata import testdata, wrap_testdata 11 | 12 | from .models import Author 13 | 14 | if django.VERSION >= (3, 2): 15 | raise SkipTest("Django 3.2 includes functionality.") 16 | 17 | 18 | class UnDeepCopyAble(object): 19 | def __repr__(self): 20 | return str('') 21 | 22 | def __deepcopy__(self, memo): 23 | raise TypeError('Not deep copyable.') 24 | 25 | 26 | def assert_no_queries(test): 27 | @wraps(test) 28 | def inner(self): 29 | with self.assertNumQueries(0): 30 | test(self) 31 | return inner 32 | 33 | 34 | class TestDataTests(TestCase): 35 | wrapper = testdata 36 | undeepcopyable_message = ( 37 | " must be deepcopy'able to be wrapped in testdata." 38 | ) 39 | 40 | @classmethod 41 | def setUpTestData(cls): 42 | cls.aldous = cls.wrapper(Author.objects.create( 43 | name='Aldous Huxley', 44 | )) 45 | cls.brave_new_world = cls.wrapper(cls.aldous.books.create( 46 | title='Brave New World', 47 | )) 48 | cls.books = cls.wrapper([ 49 | cls.aldous.books.create( 50 | title='Chrome Yellow' 51 | ), 52 | cls.brave_new_world, 53 | ]) 54 | cls.unpickleable = cls.wrapper(UnDeepCopyAble()) 55 | 56 | @assert_no_queries 57 | def access_testdata(self): 58 | self.aldous 59 | 60 | @assert_no_queries 61 | def test_attributes_cleanup(self): 62 | """Attributes assigned during """ 63 | test = TestDataTests('access_testdata') 64 | pre_attributes = set(test.__dict__) 65 | test.run() 66 | post_attributes = set(test.__dict__) 67 | self.assertEqual(pre_attributes, post_attributes) 68 | 69 | @assert_no_queries 70 | def test_class_attribute_equality(self): 71 | """Class level test data is equal to instance level test data.""" 72 | self.assertEqual(self.aldous, self.__class__.aldous) 73 | 74 | @assert_no_queries 75 | def test_class_attribute_identity(self): 76 | """Class level test data is not identical to instance level test data.""" 77 | self.assertIsNot(self.aldous, self.__class__.aldous) 78 | 79 | @assert_no_queries 80 | def test_identity_preservation(self): 81 | """Identity of test data is preserved between accesses.""" 82 | self.assertIs(self.aldous, self.aldous) 83 | 84 | @assert_no_queries 85 | def test_known_related_objects_identity_preservation(self): 86 | """Known related objects identity is preserved.""" 87 | self.assertIs(self.aldous, self.brave_new_world.author) 88 | 89 | @assert_no_queries 90 | def test_list_object(self): 91 | self.assertIs(self.books[0].author, self.aldous) 92 | self.assertIs(self.books[-1], self.brave_new_world) 93 | 94 | def test_undeepcopyable(self): 95 | with self.assertRaisesMessage(TypeError, self.undeepcopyable_message): 96 | self.unpickleable 97 | 98 | 99 | class WrapTestDataTests(TestDataTests): 100 | undeepcopyable_message = ( 101 | "tests.tests.WrapTestDataTests.unpickleable must be deepcopy'able to be wrapped in testdata." 102 | ) 103 | 104 | @staticmethod 105 | def wrapper(wrapped): 106 | return wrapped 107 | 108 | @classmethod 109 | @wrap_testdata 110 | def setUpTestData(cls): 111 | super(WrapTestDataTests, cls).setUpTestData() 112 | 113 | @assert_no_queries 114 | def test_name_attribute_assignment(self): 115 | """Name assignment through testdata(name) allows dict caching.""" 116 | self.assertNotIn('aldous', self.__dict__) 117 | self.aldous 118 | self.assertIn('aldous', self.__dict__) 119 | self.assertIs(self.__dict__['aldous'], self.aldous) 120 | 121 | 122 | class IntegrationTests(TestCase): 123 | @classmethod 124 | @wrap_testdata 125 | def setUpTestData(cls): 126 | cls.author = Author.objects.create( 127 | name='Milan Kundera', 128 | ) 129 | cls.book = cls.author.books.create( 130 | title='Nesnesitelná lehkost bytí', 131 | ) 132 | 133 | def test_book_name_english(self): 134 | self.assertEqual(self.book.title, 'Nesnesitelná lehkost bytí') 135 | self.book.title = 'The Unbearable Lightness of Being' 136 | self.book.save() 137 | 138 | def test_book_name_french(self): 139 | self.assertEqual(self.book.title, 'Nesnesitelná lehkost bytí') 140 | self.book.title = "L'Insoutenable Légèreté de l'être" 141 | self.book.save() 142 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | skipsdist = true 3 | args_are_paths = false 4 | envlist = 5 | py27-flake8 6 | py27-isort 7 | py27-1.11 8 | py35-{1.11,2.1,2.2} 9 | py36-{1.11,2.1,2.2,3.0,3.1,3.2} 10 | py37-{1.11,2.1,2.2,3.0,3.1,3.2} 11 | py38-{2.2,3.0,3.1,3.2} 12 | py39-{2.2,3.0,3.1,3.2} 13 | 14 | [testenv] 15 | basepython = 16 | py27: python2.7 17 | py35: python3.5 18 | py36: python3.6 19 | py37: python3.7 20 | py38: python3.8 21 | py39: python3.9 22 | usedevelop = true 23 | commands = 24 | {envpython} -R -Wonce {envbindir}/coverage run -m django test -v2 --settings=tests.settings {posargs} 25 | coverage report 26 | deps = 27 | coverage 28 | 1.11: Django>=1.11,<2.0 29 | 2.1: Django>=2.1,<2.2 30 | 2.2: Django>=2.2,<3.0 31 | 3.0: Django>=3.0,<3.1 32 | 3.1: Django>=3.1,<3.2 33 | 3.2: Django>=3.2,<4.0 34 | 35 | [testenv:py27-flake8] 36 | usedevelop = false 37 | basepython = python2.7 38 | commands = flake8 39 | deps = flake8 40 | 41 | [testenv:py27-isort] 42 | usedevelop = false 43 | basepython = python2.7 44 | commands = isort --recursive --check-only --diff testdata tests 45 | deps = isort==4.2.5 46 | --------------------------------------------------------------------------------