├── .gitignore ├── .travis.yml ├── LICENSE ├── README.rst ├── django_testmigrate ├── __init__.py ├── base.py └── management │ ├── __init__.py │ └── commands │ ├── __init__.py │ └── testmigrate.py ├── manage.py ├── setup.cfg ├── setup.py ├── test_project ├── __init__.py ├── failure_settings.py ├── settings.py ├── success_reverse_halted_settings.py ├── success_settings.py ├── test_failure │ ├── __init__.py │ ├── migrations │ │ ├── 0001_initial.py │ │ ├── 0002_auto_20150109_1754.py │ │ └── __init__.py │ └── models.py ├── test_success │ ├── __init__.py │ ├── migrations │ │ ├── 0001_initial.py │ │ ├── 0002_auto_20150109_1754.py │ │ ├── 0003_auto_20150109_2348.py │ │ ├── 0004_mymodel_title.py │ │ ├── 0005_remove_mymodel_title.py │ │ └── __init__.py │ └── models.py ├── test_success_reverse_halted │ ├── __init__.py │ ├── migrations │ │ ├── 0001_initial.py │ │ ├── 0002_auto_20150109_1754.py │ │ ├── 0003_auto_20150109_2348.py │ │ └── __init__.py │ └── models.py ├── urls.py └── wsgi.py ├── tests.py └── tox.ini /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | lib/ 17 | lib64/ 18 | parts/ 19 | sdist/ 20 | var/ 21 | *.egg-info/ 22 | .installed.cfg 23 | *.egg 24 | 25 | # PyInstaller 26 | # Usually these files are written by a python script from a template 27 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 28 | *.manifest 29 | *.spec 30 | 31 | # Installer logs 32 | pip-log.txt 33 | pip-delete-this-directory.txt 34 | 35 | # Unit test / coverage reports 36 | htmlcov/ 37 | .tox/ 38 | .coverage 39 | .cache 40 | nosetests.xml 41 | coverage.xml 42 | 43 | # Translations 44 | *.mo 45 | *.pot 46 | 47 | # Django stuff: 48 | *.log 49 | 50 | # Sphinx documentation 51 | docs/_build/ 52 | 53 | # PyBuilder 54 | target/ 55 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | 3 | python: 4 | - "2.7" 5 | - "3.4" 6 | 7 | env: 8 | - DJANGO_VERSION=dj18 9 | - DJANGO_VERSION=dj19 10 | - DJANGO_VERSION=djdev 11 | 12 | matrix: 13 | allow_failures: 14 | - env: DJANGO_VERSION=dj19 15 | - env: DJANGO_VERSION=djdev 16 | include: 17 | - python: "2.7" 18 | env: DJANGO_VERSION=flake8 19 | 20 | cache: 21 | directories: 22 | - $HOME/.cache/pip 23 | 24 | before_cache: 25 | - rm -f $HOME/.cache/pip/log/debug.log 26 | 27 | # command to install dependencies 28 | install: 29 | - pip install -U pip 30 | - pip install -U wheel virtualenv 31 | - pip install tox coveralls 32 | 33 | after_success: 34 | - coveralls 35 | 36 | # command to run tests 37 | script: 38 | - coverage erase 39 | - tox -e py${TRAVIS_PYTHON_VERSION/./}-${DJANGO_VERSION} 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015, via680 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, 5 | are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 10 | * Redistributions in binary form must reproduce the above copyright notice, this 11 | list of conditions and the following disclaimer in the documentation and/or 12 | other materials provided with the distribution. 13 | 14 | * Neither the name of the {organization} nor the names of its 15 | contributors may be used to endorse or promote products derived from 16 | this software without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 19 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 20 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR 22 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 23 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 24 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 25 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 27 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ================== 2 | django-testmigrate 3 | ================== 4 | 5 | .. image:: https://travis-ci.org/greyside/django-testmigrate.svg?branch=master 6 | :target: https://travis-ci.org/greyside/django-testmigrate 7 | .. image:: https://coveralls.io/repos/greyside/django-testmigrate/badge.png?branch=master 8 | :target: https://coveralls.io/r/greyside/django-testmigrate?branch=master 9 | 10 | Run automated tests for your migrations. 11 | 12 | ------- 13 | Install 14 | ------- 15 | 16 | :: 17 | 18 | pip install django-testmigrate 19 | 20 | Add "django_testmigrate" to your INSTALLED_APPS. 21 | 22 | -------------- 23 | Run Your Tests 24 | -------------- 25 | 26 | This command creates and then destroys a test database just like the Django test runner does. Migrations are run all the way forward and then as far backwards as possible. 27 | 28 | :: 29 | 30 | ./manage.py testmigrate 31 | 32 | ---------------------------- 33 | Add Tests to Your Migrations 34 | ---------------------------- 35 | 36 | Four optional test methods can be added to your migration classes: 37 | 38 | * test_apply_start - Run before this migration. 39 | * test_apply_success - Run after this migration. 40 | * test_unapply_start - Run before this migration is reversed. 41 | * test_unapply_success - Run after this migration is reversed. 42 | 43 | They will not be run during the normal migration command, only during "testmigrate". This makes it possible to create models in your test database that you can write assertions against. 44 | 45 | .. code:: python 46 | 47 | class Migration(migrations.Migration): 48 | 49 | dependencies = [ 50 | ('myapp', '0001_initial'), 51 | ] 52 | 53 | operations = [ 54 | migrations.RunPython( 55 | populate_data, 56 | populate_data_rev, 57 | ), 58 | ] 59 | 60 | def test_apply_start(self, apps, testcase): 61 | """ 62 | self - This migration instance. 63 | apps - The project_state for this app, same as what's passed in to 64 | functions for migrations.RunPython. 65 | testcase - A TestCase instance to provide the assertions you're used to. 66 | This instance is shared between migration test functions in case you 67 | need to share state. 68 | """ 69 | MyModel = apps.get_model("myapp", "MyModel") 70 | 71 | testcase.assertEqual(MyModel.objects.count(), 5) 72 | 73 | def test_apply_success(self, apps, testcase): 74 | pass 75 | 76 | def test_unapply_start(self, apps, testcase): 77 | pass 78 | 79 | def test_unapply_success(self, apps, testcase): 80 | pass 81 | 82 | Please note, `apps.get_model()` will likely not work in 0001 migrations, since the app hasn't been registered yet. If you need to create data, put it in `test_apply_start()` for that app's 0002 migration. 83 | 84 | -------------------------------------------------------------------------------- /django_testmigrate/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (C) 2015 via680 3 | # 4 | # Licensed under a BSD 3-Clause License. See LICENSE file. 5 | 6 | VERSION = (0, 4, 0) 7 | 8 | __version__ = "".join([".".join(map(str, VERSION[0:3])), "".join(VERSION[3:])]) 9 | -------------------------------------------------------------------------------- /django_testmigrate/base.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (C) 2015 via680 3 | # 4 | # Licensed under a BSD 3-Clause License. See LICENSE file. 5 | from __future__ import unicode_literals 6 | 7 | from collections import OrderedDict 8 | 9 | from django.core.management.commands import migrate 10 | from django.db import models 11 | from django.test import SimpleTestCase 12 | 13 | 14 | class StateScope(object): 15 | pass 16 | 17 | 18 | class MigrateTestCase(SimpleTestCase): 19 | _current_state = None 20 | 21 | def __init__(self, *args, **kwargs): 22 | self._store = OrderedDict() 23 | self.set_current_state(None) 24 | 25 | super(MigrateTestCase, self).__init__(*args, **kwargs) 26 | 27 | def test(self): 28 | return 29 | 30 | def set_current_state(self, state): 31 | self._current_state = state 32 | 33 | if state not in self._store: 34 | self._store[state] = StateScope() 35 | 36 | def __setattr__(self, name, value): 37 | if isinstance(value, models.Model): 38 | setattr(self._store[self._current_state], name, value) 39 | else: 40 | super(MigrateTestCase, self).__setattr__(name, value) 41 | 42 | def __getattr__(self, name): 43 | e = None 44 | 45 | for state, scope in reversed(list(self._store.items())): 46 | try: 47 | val = getattr(scope, name) 48 | except AttributeError as ex: 49 | # needed for Python 3 compatibility 50 | e = ex 51 | continue 52 | else: 53 | e = None 54 | 55 | # get fresh instance 56 | if state != self._current_state and \ 57 | isinstance(val, models.Model): 58 | model_meta = val.__class__._meta 59 | model_class = self._current_state.get_model( 60 | model_meta.app_label, 61 | model_meta.model_name 62 | ) 63 | val = model_class.objects.get(pk=val.pk) 64 | 65 | # add this value to the current scope 66 | setattr(self, name, val) 67 | break 68 | 69 | if e: 70 | raise AttributeError("'%s' object has no attribute '%s'" % ( 71 | self.__class__.__name__, name 72 | )) 73 | 74 | return val 75 | 76 | 77 | class TestMigrationExecutor(migrate.MigrationExecutor): 78 | def run_test_func(self, action, *args): 79 | command = TestMigrationExecutor.command 80 | 81 | state, migration = args 82 | 83 | test_func_name = 'test_%s' % action 84 | 85 | forwards = action.startswith('apply') 86 | 87 | test_func = getattr(migration, test_func_name, None) 88 | 89 | if not test_func: 90 | return 91 | 92 | if command.verbosity > 0: 93 | whitespace = '' if forwards else ' ' 94 | command.stdout.write(' %sRunning %s.%s.%s...' % ( 95 | whitespace, 96 | migration.app_label, 97 | migration.name, 98 | test_func_name 99 | ), command.style.MIGRATE_SUCCESS, ending='') 100 | 101 | rendered_state = state.apps 102 | 103 | command.testcase.set_current_state(rendered_state) 104 | 105 | test_func(rendered_state, command.testcase) 106 | 107 | if command.verbosity > 0: 108 | command.stdout.write(' OK', command.style.MIGRATE_SUCCESS) 109 | 110 | def apply_migration(self, *args, **kwargs): 111 | self.run_test_func("apply_start", *args) 112 | 113 | super(TestMigrationExecutor, self).apply_migration(*args, **kwargs) 114 | 115 | self.run_test_func("apply_success", *args) 116 | 117 | def unapply_migration(self, *args, **kwargs): 118 | self.run_test_func("unapply_start", *args) 119 | 120 | super(TestMigrationExecutor, self).unapply_migration(*args, **kwargs) 121 | 122 | self.run_test_func("unapply_success", *args) 123 | 124 | 125 | class TestMigrateCommand(migrate.Command): 126 | def handle(self, *args, **kwargs): 127 | 128 | self.testcase = MigrateTestCase('test') 129 | 130 | TestMigrationExecutor.command = self 131 | 132 | old = migrate.MigrationExecutor 133 | migrate.MigrationExecutor = TestMigrationExecutor 134 | 135 | # Django's test runner code will mess with the verbosity, fix it here. 136 | from django_testmigrate.management.commands.testmigrate import Command 137 | kwargs['verbosity'] = Command.verbosity 138 | 139 | super(TestMigrateCommand, self).handle(*args, **kwargs) 140 | 141 | migrate.MigrationExecutor = old 142 | -------------------------------------------------------------------------------- /django_testmigrate/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/greyside/django-testmigrate/6932bea50011543df528065e9b4b3ccd834862be/django_testmigrate/management/__init__.py -------------------------------------------------------------------------------- /django_testmigrate/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/greyside/django-testmigrate/6932bea50011543df528065e9b4b3ccd834862be/django_testmigrate/management/commands/__init__.py -------------------------------------------------------------------------------- /django_testmigrate/management/commands/testmigrate.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.core.management.base import BaseCommand 5 | from django.db import connections, DEFAULT_DB_ALIAS, migrations 6 | from django.test.runner import DiscoverRunner 7 | 8 | from ...base import TestMigrateCommand, TestMigrationExecutor 9 | 10 | 11 | option_list = [ 12 | (('--db-name-suffix',), {'action': 'store', 13 | 'dest': 'db_name_suffix', 'default': '', 14 | 'help': 'String appended to the test database name.'}), 15 | (('--database',), {'action': 'store', 16 | 'dest': 'database', 'default': DEFAULT_DB_ALIAS, 17 | 'help': 'Nominates a database to synchronize. Defaults to the ' 18 | '"default" database.'}), 19 | (('--no-reverse',), {'action': 'store_true', 20 | 'dest': 'no_reverse', 'default': False, 21 | 'help': 'Migrate backwards after migrating forwards.'}), 22 | ] 23 | 24 | 25 | class Command(BaseCommand): 26 | can_import_settings = True 27 | 28 | def add_arguments(self, parser): 29 | [ 30 | parser.add_argument(*args, **kwargs) 31 | for args, kwargs in option_list 32 | ] 33 | 34 | def handle(self, *args, **options): 35 | self.verbosity = int(options.get('verbosity', 1)) 36 | self.no_reverse = options.get('no_reverse') 37 | self.db_name_suffix = options.get('db_name_suffix') 38 | 39 | # need this available later 40 | Command.verbosity = self.verbosity 41 | 42 | from django.core.management.commands import migrate 43 | old_Command = migrate.Command 44 | migrate.Command = TestMigrateCommand 45 | 46 | runner = DiscoverRunner(verbosity=self.verbosity) 47 | runner.setup_test_environment() 48 | 49 | # this will do our forwards migrations 50 | try: 51 | if self.verbosity > 0: 52 | self.stdout.write('Running all forwards migrations.', 53 | self.style.WARNING) 54 | 55 | old_config = runner.setup_databases( 56 | db_name_suffix=self.db_name_suffix 57 | ) 58 | 59 | # unless the user asked us not to, migrate backwards. 60 | if not self.no_reverse: 61 | db = options.get('database') 62 | connection = connections[db] 63 | 64 | executor = TestMigrationExecutor(connection) 65 | # we filter out contenttypes because it causes errors when 66 | # migrated all the way back. 67 | root_nodes = [ 68 | node 69 | for node in executor.loader.graph.root_nodes() 70 | if node[0] != 'contenttypes' 71 | ] 72 | 73 | for app_label, migration_name in root_nodes: 74 | if self.verbosity > 0: 75 | self.stdout.write('Running reverse migrations for %s.' 76 | % app_label, self.style.WARNING) 77 | try: 78 | TestMigrateCommand().execute( 79 | app_label, 80 | migration_name, 81 | app_label=app_label, 82 | migration_name=migration_name, 83 | verbosity=self.verbosity, 84 | interactive=False, 85 | database=connection.alias, 86 | test_database=True, 87 | test_flush=True, 88 | ) 89 | except migrations.Migration.IrreversibleError as e: 90 | if self.verbosity > 0: 91 | self.stdout.write('\n Warning in %s.%s: %s' % 92 | (app_label, migration_name, e), 93 | self.style.WARNING) 94 | except: 95 | raise 96 | else: 97 | # this doesn't go in the finally block since we might want to 98 | # inspect the DB after a failure. 99 | runner.teardown_databases(old_config) 100 | finally: 101 | migrate.Command = old_Command 102 | runner.teardown_test_environment() 103 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "test_project.settings") 7 | 8 | from django.core.management import execute_from_command_line 9 | 10 | execute_from_command_line(sys.argv) 11 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [wheel] 2 | universal = 1 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | from setuptools import setup, find_packages 5 | import django_testmigrate 6 | 7 | EXCLUDE_FROM_PACKAGES = ['test_project*'] 8 | 9 | setup(name='django-testmigrate', 10 | version=django_testmigrate.__version__, 11 | description="Lets you write test functions for your migrations.", 12 | author='Seán Hayes', 13 | author_email='sean@seanhayes.name', 14 | classifiers=[ 15 | "Development Status :: 3 - Alpha", 16 | "Intended Audience :: Developers", 17 | "License :: OSI Approved :: BSD License", 18 | "Programming Language :: Python", 19 | "Topic :: Software Development :: Build Tools", 20 | "Topic :: Software Development :: Libraries", 21 | "Topic :: Software Development :: Libraries :: Python Modules" 22 | ], 23 | keywords='django testmigrate migrations', 24 | url='https://github.com/greyside/django-testmigrate', 25 | download_url='https://github.com/greyside/django-testmigrate', 26 | license='BSD', 27 | install_requires=[ 28 | 'django>=1.8', 29 | 'six', 30 | ], 31 | tests_require=[ 32 | 'coverage', 33 | 'mock' 34 | ], 35 | packages=find_packages(exclude=EXCLUDE_FROM_PACKAGES), 36 | include_package_data=True, 37 | zip_safe=False, 38 | test_suite='tests', 39 | ) 40 | 41 | -------------------------------------------------------------------------------- /test_project/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/greyside/django-testmigrate/6932bea50011543df528065e9b4b3ccd834862be/test_project/__init__.py -------------------------------------------------------------------------------- /test_project/failure_settings.py: -------------------------------------------------------------------------------- 1 | from .settings import * 2 | 3 | INSTALLED_APPS += ('test_project.test_failure',) 4 | -------------------------------------------------------------------------------- /test_project/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for test_project project. 3 | 4 | For more information on this file, see 5 | https://docs.djangoproject.com/en/1.7/topics/settings/ 6 | 7 | For the full list of settings and their values, see 8 | https://docs.djangoproject.com/en/1.7/ref/settings/ 9 | """ 10 | 11 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 12 | import os 13 | BASE_DIR = os.path.dirname(os.path.dirname(__file__)) 14 | 15 | 16 | # Quick-start development settings - unsuitable for production 17 | # See https://docs.djangoproject.com/en/1.7/howto/deployment/checklist/ 18 | 19 | # SECURITY WARNING: keep the secret key used in production secret! 20 | SECRET_KEY = 'f8fkupu8pa%%u$wgk6c!os39el41v7i7^u*8xf#g5p@+c&)b#^' 21 | 22 | # SECURITY WARNING: don't run with debug turned on in production! 23 | DEBUG = True 24 | 25 | TEMPLATE_DEBUG = True 26 | 27 | ALLOWED_HOSTS = [] 28 | 29 | 30 | # Application definition 31 | 32 | INSTALLED_APPS = ( 33 | 'django.contrib.admin', 34 | 'django.contrib.auth', 35 | 'django.contrib.contenttypes', 36 | 'django.contrib.sessions', 37 | 'django.contrib.messages', 38 | 'django.contrib.staticfiles', 39 | 40 | 'django_testmigrate', 41 | ) 42 | 43 | MIDDLEWARE_CLASSES = ( 44 | 'django.contrib.sessions.middleware.SessionMiddleware', 45 | 'django.middleware.common.CommonMiddleware', 46 | 'django.middleware.csrf.CsrfViewMiddleware', 47 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 48 | 'django.contrib.auth.middleware.SessionAuthenticationMiddleware', 49 | 'django.contrib.messages.middleware.MessageMiddleware', 50 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 51 | ) 52 | 53 | ROOT_URLCONF = 'test_project.urls' 54 | 55 | WSGI_APPLICATION = 'test_project.wsgi.application' 56 | 57 | 58 | # Database 59 | # https://docs.djangoproject.com/en/1.7/ref/settings/#databases 60 | 61 | DATABASES = { 62 | 'default': { 63 | 'ENGINE': 'django.db.backends.sqlite3', 64 | 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), 65 | } 66 | } 67 | 68 | # Internationalization 69 | # https://docs.djangoproject.com/en/1.7/topics/i18n/ 70 | 71 | LANGUAGE_CODE = 'en-us' 72 | 73 | TIME_ZONE = 'UTC' 74 | 75 | USE_I18N = True 76 | 77 | USE_L10N = True 78 | 79 | USE_TZ = True 80 | 81 | 82 | # Static files (CSS, JavaScript, Images) 83 | # https://docs.djangoproject.com/en/1.7/howto/static-files/ 84 | 85 | STATIC_URL = '/static/' 86 | -------------------------------------------------------------------------------- /test_project/success_reverse_halted_settings.py: -------------------------------------------------------------------------------- 1 | from .settings import * 2 | 3 | INSTALLED_APPS += ('test_project.test_success_reverse_halted',) 4 | -------------------------------------------------------------------------------- /test_project/success_settings.py: -------------------------------------------------------------------------------- 1 | from .settings import * 2 | 3 | INSTALLED_APPS += ('test_project.test_success',) 4 | -------------------------------------------------------------------------------- /test_project/test_failure/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/greyside/django-testmigrate/6932bea50011543df528065e9b4b3ccd834862be/test_project/test_failure/__init__.py -------------------------------------------------------------------------------- /test_project/test_failure/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ] 11 | 12 | operations = [ 13 | migrations.CreateModel( 14 | name='MyModel', 15 | fields=[ 16 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 17 | ('text', models.TextField()), 18 | ], 19 | options={ 20 | }, 21 | bases=(models.Model,), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /test_project/test_failure/migrations/0002_auto_20150109_1754.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('test_failure', '0001_initial'), 11 | ] 12 | 13 | operations = [ 14 | migrations.RunPython(lambda apps, schema_editor: None, lambda apps, schema_editor: None), 15 | ] 16 | 17 | def test_unapply_success(self, apps, testcase): 18 | testcase.assertFalse(True) 19 | -------------------------------------------------------------------------------- /test_project/test_failure/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/greyside/django-testmigrate/6932bea50011543df528065e9b4b3ccd834862be/test_project/test_failure/migrations/__init__.py -------------------------------------------------------------------------------- /test_project/test_failure/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | # Create your models here. 4 | 5 | class MyModel(models.Model): 6 | text = models.TextField() 7 | -------------------------------------------------------------------------------- /test_project/test_success/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/greyside/django-testmigrate/6932bea50011543df528065e9b4b3ccd834862be/test_project/test_success/__init__.py -------------------------------------------------------------------------------- /test_project/test_success/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ] 11 | 12 | operations = [ 13 | migrations.CreateModel( 14 | name='MyModel', 15 | fields=[ 16 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 17 | ('text', models.TextField()), 18 | ], 19 | options={ 20 | }, 21 | bases=(models.Model,), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /test_project/test_success/migrations/0002_auto_20150109_1754.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | 6 | 7 | def populate_data(apps, schema_editor): 8 | MyModel = apps.get_model("test_success", "MyModel") 9 | 10 | MyModel.objects.update(text='foo') 11 | 12 | 13 | def populate_data_rev(apps, schema_editor): 14 | MyModel = apps.get_model("test_success", "MyModel") 15 | 16 | MyModel.objects.update(text='') 17 | 18 | 19 | class Migration(migrations.Migration): 20 | 21 | dependencies = [ 22 | ('test_success', '0001_initial'), 23 | ] 24 | 25 | operations = [ 26 | migrations.RunPython( 27 | populate_data, 28 | populate_data_rev, 29 | ), 30 | ] 31 | 32 | def test_apply_start(self, apps, testcase): 33 | MyModel = apps.get_model("test_success", "MyModel") 34 | 35 | testcase.assertTrue(True) 36 | 37 | testcase.mymodel1 = MyModel.objects.create() 38 | 39 | def test_apply_success(self, apps, testcase): 40 | testcase.assertTrue(True) 41 | 42 | def test_unapply_start(self, apps, testcase): 43 | testcase.assertTrue(True) 44 | 45 | def test_unapply_success(self, apps, testcase): 46 | testcase.assertTrue(True) 47 | -------------------------------------------------------------------------------- /test_project/test_success/migrations/0003_auto_20150109_2348.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | 6 | 7 | def populate_data(apps, schema_editor): 8 | MyModel = apps.get_model("test_success", "MyModel") 9 | 10 | MyModel.objects.update(text='bar') 11 | 12 | 13 | def populate_data_rev(apps, schema_editor): 14 | MyModel = apps.get_model("test_success", "MyModel") 15 | 16 | MyModel.objects.update(text='foo') 17 | 18 | 19 | class Migration(migrations.Migration): 20 | 21 | dependencies = [ 22 | ('test_success', '0002_auto_20150109_1754'), 23 | ] 24 | 25 | operations = [ 26 | migrations.RunPython( 27 | populate_data, 28 | populate_data_rev, 29 | ), 30 | ] 31 | -------------------------------------------------------------------------------- /test_project/test_success/migrations/0004_mymodel_title.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('test_success', '0003_auto_20150109_2348'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name='mymodel', 16 | name='title', 17 | field=models.CharField(default=b'', max_length=100), 18 | preserve_default=True, 19 | ), 20 | ] 21 | 22 | def test_apply_success(self, apps, testcase): 23 | testcase.mymodel1.save() 24 | -------------------------------------------------------------------------------- /test_project/test_success/migrations/0005_remove_mymodel_title.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('test_success', '0004_mymodel_title'), 11 | ] 12 | 13 | operations = [ 14 | migrations.RemoveField( 15 | model_name='mymodel', 16 | name='title', 17 | ), 18 | ] 19 | 20 | def test_apply_success(self, apps, testcase): 21 | testcase.mymodel1.save() 22 | -------------------------------------------------------------------------------- /test_project/test_success/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/greyside/django-testmigrate/6932bea50011543df528065e9b4b3ccd834862be/test_project/test_success/migrations/__init__.py -------------------------------------------------------------------------------- /test_project/test_success/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | # Create your models here. 4 | 5 | class MyModel(models.Model): 6 | text = models.TextField() 7 | -------------------------------------------------------------------------------- /test_project/test_success_reverse_halted/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/greyside/django-testmigrate/6932bea50011543df528065e9b4b3ccd834862be/test_project/test_success_reverse_halted/__init__.py -------------------------------------------------------------------------------- /test_project/test_success_reverse_halted/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ] 11 | 12 | operations = [ 13 | migrations.CreateModel( 14 | name='MyModel', 15 | fields=[ 16 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 17 | ('text', models.TextField()), 18 | ], 19 | options={ 20 | }, 21 | bases=(models.Model,), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /test_project/test_success_reverse_halted/migrations/0002_auto_20150109_1754.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | 6 | 7 | def populate_data(apps, schema_editor): 8 | MyModel = apps.get_model("test_success_reverse_halted", "MyModel") 9 | 10 | MyModel.objects.update(text='foo') 11 | 12 | 13 | class Migration(migrations.Migration): 14 | 15 | dependencies = [ 16 | ('test_success_reverse_halted', '0001_initial'), 17 | ] 18 | 19 | operations = [ 20 | migrations.RunPython( 21 | populate_data, 22 | ), 23 | ] 24 | 25 | def test_apply_start(self, apps, testcase): 26 | testcase.assertTrue(True) 27 | 28 | def test_apply_success(self, apps, testcase): 29 | testcase.assertTrue(True) 30 | 31 | def test_unapply_start(self, apps, testcase): 32 | testcase.assertTrue(True) 33 | -------------------------------------------------------------------------------- /test_project/test_success_reverse_halted/migrations/0003_auto_20150109_2348.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | 6 | 7 | def populate_data(apps, schema_editor): 8 | MyModel = apps.get_model("test_success_reverse_halted", "MyModel") 9 | 10 | MyModel.objects.update(text='bar') 11 | 12 | 13 | def populate_data_rev(apps, schema_editor): 14 | MyModel = apps.get_model("test_success_reverse_halted", "MyModel") 15 | 16 | MyModel.objects.update(text='foo') 17 | 18 | 19 | class Migration(migrations.Migration): 20 | 21 | dependencies = [ 22 | ('test_success_reverse_halted', '0002_auto_20150109_1754'), 23 | ] 24 | 25 | operations = [ 26 | migrations.RunPython( 27 | populate_data, 28 | populate_data_rev, 29 | ), 30 | ] 31 | -------------------------------------------------------------------------------- /test_project/test_success_reverse_halted/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/greyside/django-testmigrate/6932bea50011543df528065e9b4b3ccd834862be/test_project/test_success_reverse_halted/migrations/__init__.py -------------------------------------------------------------------------------- /test_project/test_success_reverse_halted/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | # Create your models here. 4 | 5 | class MyModel(models.Model): 6 | text = models.TextField() 7 | -------------------------------------------------------------------------------- /test_project/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import patterns, include, url 2 | from django.contrib import admin 3 | 4 | urlpatterns = patterns('', 5 | # Examples: 6 | # url(r'^$', 'test_project.views.home', name='home'), 7 | # url(r'^blog/', include('blog.urls')), 8 | 9 | url(r'^admin/', include(admin.site.urls)), 10 | ) 11 | -------------------------------------------------------------------------------- /test_project/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for test_project project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/1.7/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "test_project.settings") 12 | 13 | from django.core.wsgi import get_wsgi_application 14 | application = get_wsgi_application() 15 | -------------------------------------------------------------------------------- /tests.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function 2 | 3 | from collections import OrderedDict 4 | import subprocess 5 | from unittest import TestCase 6 | 7 | try: 8 | from unittest import mock 9 | except ImportError: 10 | import mock 11 | 12 | 13 | class MigrationTestCase(TestCase): 14 | executable = 'coverage run -a --source=django_testmigrate,test_project/test_success/migrations,test_project/test_failure/migrations,test_project/test_success_reverse_halted/migrations' 15 | cmd = executable+" manage.py testmigrate --settings=test_project.%s_settings" 16 | 17 | def test_success_reverse_migrations_complete(self): 18 | process = subprocess.Popen( 19 | self.cmd % 'success', 20 | shell=True, 21 | stdout=subprocess.PIPE, 22 | stderr=subprocess.PIPE 23 | ) 24 | 25 | stdout, stderr = process.communicate() 26 | 27 | print('\n', stdout, stderr) 28 | 29 | self.assertEqual(process.returncode, 0) 30 | self.assertNotEqual(stdout, b'') 31 | self.assertEqual(stderr, b'') 32 | 33 | def test_success_reverse_migrations_halted(self): 34 | process = subprocess.Popen( 35 | self.cmd % 'success_reverse_halted', 36 | shell=True, 37 | stdout=subprocess.PIPE, 38 | stderr=subprocess.PIPE 39 | ) 40 | 41 | stdout, stderr = process.communicate() 42 | 43 | print('\n', stdout, stderr) 44 | 45 | self.assertEqual(process.returncode, 0) 46 | self.assertNotEqual(stdout, b'') 47 | self.assertEqual(stderr, b'') 48 | 49 | def test_failure(self): 50 | """ 51 | The failure occurs while migrating backwards, so if we detect an error 52 | we'll know backwards migrations ran. 53 | """ 54 | process = subprocess.Popen( 55 | self.cmd % 'failure', 56 | shell=True, 57 | stdout=subprocess.PIPE, 58 | stderr=subprocess.PIPE 59 | ) 60 | 61 | stdout, stderr = process.communicate() 62 | 63 | print(stdout, stderr) 64 | 65 | self.assertEqual(process.returncode, 1) 66 | self.assertNotEqual(stdout, b'') 67 | self.assertNotEqual(stderr, b'') 68 | 69 | def test_success_no_backwards_migrations(self): 70 | """ 71 | If we don't migrate backwards, we won't reach the assertion failure. 72 | """ 73 | process = subprocess.Popen( 74 | (self.cmd+' --no-reverse') % 'failure', 75 | shell=True, 76 | stdout=subprocess.PIPE, 77 | stderr=subprocess.PIPE 78 | ) 79 | 80 | stdout, stderr = process.communicate() 81 | 82 | print(stdout, stderr) 83 | 84 | self.assertEqual(process.returncode, 0) 85 | self.assertNotEqual(stdout, b'') 86 | self.assertEqual(stderr, b'') 87 | 88 | 89 | class MigrateTestCaseTestCase(TestCase): 90 | def test_scope(self): 91 | from django.conf import settings 92 | settings.configure() 93 | from django.contrib.auth.models import User 94 | from django_testmigrate.base import MigrateTestCase, StateScope 95 | 96 | MockUser = mock.Mock() 97 | MockUser.objects.get.side_effect = lambda *args, **kwargs: User(*args, **kwargs) 98 | 99 | test_case = MigrateTestCase('test') 100 | 101 | # try to get variable before there's a scope set. 102 | with self.assertRaises(AttributeError): 103 | test_case.baz 104 | 105 | self.assertTrue(isinstance(test_case._store, OrderedDict)) 106 | self.assertTrue(isinstance(test_case._store[None], StateScope)) 107 | 108 | state1 = mock.Mock() 109 | 110 | test_case.set_current_state(state1) 111 | test_case.foo = 'a string' 112 | state1model1 = test_case.model1 = User(id=1) 113 | 114 | self.assertTrue(isinstance(test_case._store[state1], StateScope)) 115 | 116 | # models get scoped, but other values stored directly on MigrateTestCase 117 | self.assertIs(test_case.__dict__['foo'], test_case.foo) 118 | self.assertNotIn('model1', test_case.__dict__) 119 | self.assertEqual(test_case._store[state1].model1, state1model1) 120 | self.assertIs(test_case._store[state1].model1, state1model1) 121 | self.assertIs(test_case.model1, state1model1) 122 | 123 | with self.assertRaises(AttributeError): 124 | test_case.bar 125 | 126 | state2 = mock.Mock() 127 | 128 | test_case.set_current_state(state2) 129 | 130 | state3 = mock.Mock() 131 | state3.get_model.side_effect = lambda app_label, model_name: MockUser 132 | 133 | test_case.set_current_state(state3) 134 | 135 | self.assertIs(test_case.__dict__['foo'], test_case.foo) 136 | 137 | self.assertEqual(state3.get_model.call_count, 0) 138 | 139 | state3model1 = test_case.model1 140 | 141 | # models in different scopes should pass the equality test because they 142 | # share the same pk, but will actually be different objects. 143 | self.assertEqual(state1model1, state3model1) 144 | self.assertIsNot(state1model1, state3model1) 145 | 146 | self.assertIs(test_case._store[state1].model1, state1model1) 147 | self.assertIs(test_case._store[state3].model1, state3model1) 148 | 149 | self.assertIs(test_case.model1, state3model1) 150 | 151 | self.assertEqual(state3.get_model.call_count, 1) 152 | self.assertEqual(state3.get_model.call_args_list[0][0], (User._meta.app_label, User._meta.model_name)) 153 | 154 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | py{27,34}-djdev, 4 | py{27,34}-dj19, 5 | py{27,34}-dj18, 6 | py27-flake8 7 | skipsdist=True 8 | 9 | [testenv] 10 | commands = 11 | dj{17,18,19,dev}: {envbindir}/python setup.py test 12 | 13 | flake8: {envbindir}/flake8 --ignore=E128 --max-complexity 10 django_testmigrate 14 | 15 | deps = 16 | dj{17,18,19,dev}: coverage 17 | 18 | dj17: Django>=1.7,<1.8 19 | dj18: Django>=1.8,<1.9 20 | dj19: Django>=1.9,<1.10 21 | djdev: https://github.com/django/django/archive/master.tar.gz 22 | 23 | flake8: flake8 24 | --------------------------------------------------------------------------------