├── tests ├── __init__.py ├── app │ ├── __init__.py │ ├── models.py │ ├── views.py │ └── urls.py ├── another_app │ ├── __init__.py │ ├── views.py │ └── urls.py ├── decorators.py ├── helpers.py ├── settings.py ├── views.py ├── urls.py ├── test_command.py └── test_generator.py ├── django_smoke_tests ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ └── smoke_tests.py ├── static │ ├── img │ │ └── .gitignore │ ├── css │ │ └── django_smoke_tests.css │ └── js │ │ └── django_smoke_tests.js ├── __init__.py ├── apps.py ├── migrations.py ├── runners.py ├── templates │ └── django_smoke_tests │ │ └── base.html ├── tests.py └── generator.py ├── requirements_dev.txt ├── requirements.txt ├── MANIFEST.in ├── requirements_test.txt ├── .coveragerc ├── AUTHORS.rst ├── setup.cfg ├── manage.py ├── .github ├── ISSUE_TEMPLATE.md └── workflows │ └── actions-run-tests.yml ├── tox.ini ├── .gitignore ├── runtests.py ├── LICENSE ├── CHANGELOG.md ├── Makefile ├── setup.py ├── CONTRIBUTING.rst └── README.rst /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/app/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/another_app/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_smoke_tests/management/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_smoke_tests/static/img/.gitignore: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_smoke_tests/management/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_smoke_tests/static/css/django_smoke_tests.css: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_smoke_tests/static/js/django_smoke_tests.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_smoke_tests/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = '2.1.0' 2 | -------------------------------------------------------------------------------- /requirements_dev.txt: -------------------------------------------------------------------------------- 1 | bumpversion==0.5.3 2 | ipdb==0.13.9 3 | wheel==0.30.0 4 | 5 | -------------------------------------------------------------------------------- /tests/another_app/views.py: -------------------------------------------------------------------------------- 1 | from django.http import HttpResponse 2 | 3 | 4 | def dummy_view(request): 5 | return HttpResponse() 6 | -------------------------------------------------------------------------------- /tests/app/models.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import AbstractUser 2 | 3 | 4 | class CustomUserModel(AbstractUser): 5 | pass 6 | -------------------------------------------------------------------------------- /django_smoke_tests/apps.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 2 | from django.apps import AppConfig 3 | 4 | 5 | class DjangoSmokeTestsConfig(AppConfig): 6 | name = 'django_smoke_tests' 7 | -------------------------------------------------------------------------------- /django_smoke_tests/migrations.py: -------------------------------------------------------------------------------- 1 | class DisableMigrations(object): 2 | 3 | def __contains__(self, item): 4 | return True 5 | 6 | def __getitem__(self, item): 7 | return None 8 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # don't require Django explicitly in install_requires 2 | # to avoid upgrading Django when upgrading django_smoke_tests 3 | # Django==2.2 4 | 5 | # Additional requirements go here 6 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include AUTHORS.rst 2 | include CONTRIBUTING.rst 3 | include HISTORY.rst 4 | include LICENSE 5 | include README.rst 6 | recursive-include django_smoke_tests *.html *.png *.gif *js *.css *jpg *jpeg *svg *py 7 | -------------------------------------------------------------------------------- /requirements_test.txt: -------------------------------------------------------------------------------- 1 | coverage==4.4.1 2 | mock>=1.0.1 3 | flake8>=2.1.0 4 | tox>=1.7.0 5 | codecov>=2.0.0 6 | 7 | 8 | # Additional test requirements go here 9 | parameterized==0.6.1 10 | djangorestframework==3.11.2 11 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = true 3 | 4 | [report] 5 | omit = 6 | *site-packages* 7 | tests* 8 | django_smoke_tests/apps.py 9 | *.tox* 10 | show_missing = True 11 | exclude_lines = 12 | raise NotImplementedError 13 | -------------------------------------------------------------------------------- /AUTHORS.rst: -------------------------------------------------------------------------------- 1 | ======= 2 | Credits 3 | ======= 4 | 5 | Development Lead 6 | ---------------- 7 | 8 | * Kamil Kijak 9 | 10 | Contributors 11 | ------------ 12 | 13 | * `zvolsky `_ 14 | -------------------------------------------------------------------------------- /django_smoke_tests/runners.py: -------------------------------------------------------------------------------- 1 | from django.test.runner import DiscoverRunner 2 | 3 | 4 | class NoDbTestRunner(DiscoverRunner): 5 | """ A test runner to test without database creation """ 6 | 7 | def setup_databases(self, **kwargs): 8 | pass 9 | 10 | def teardown_databases(self, old_config, **kwargs): 11 | pass 12 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 2.1.0 3 | commit = True 4 | tag = True 5 | 6 | [bumpversion:file:django_smoke_tests/__init__.py] 7 | 8 | [wheel] 9 | universal = 1 10 | 11 | [flake8] 12 | ignore = D203 13 | exclude = 14 | django_smoke_tests/migrations, 15 | .git, 16 | .tox, 17 | docs/conf.py, 18 | build, 19 | dist 20 | max-line-length = 100 21 | 22 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | from __future__ import unicode_literals, absolute_import 4 | 5 | import os 6 | import sys 7 | 8 | if __name__ == "__main__": 9 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tests.settings") 10 | from django.core.management import execute_from_command_line 11 | 12 | execute_from_command_line(sys.argv) 13 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | * django-smoke-tests version: 2 | * Django version: 3 | * Python version: 4 | * Operating System: 5 | 6 | ### Description 7 | 8 | Describe what you were trying to get done. 9 | Tell us what happened, what went wrong, and what you expected to happen. 10 | 11 | ### What I Did 12 | 13 | ``` 14 | Paste the command(s) you ran and the output. 15 | If there was a crash, please include the traceback here. 16 | ``` 17 | -------------------------------------------------------------------------------- /tests/another_app/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from .views import dummy_view 4 | 5 | 6 | app_name = 'another_app' 7 | another_app_skipped_urls = [ 8 | path('skipped-app-endpoint-by-namespace/', dummy_view, name='skipped_endpoint_by_namespace_in_another_app'), 9 | path('skipped-app-endpoint-by-app-name/', dummy_view, name='skipped_endpoint_by_app_name_in_another_app'), 10 | ] 11 | 12 | urlpatterns = another_app_skipped_urls -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | {py37,py38,py39,py310}-django-22 4 | {py37,py38,py39,py310}-django-32 5 | 6 | [testenv] 7 | setenv = 8 | PYTHONPATH = {toxinidir}:{toxinidir}/django_smoke_tests 9 | commands = coverage run --source django_smoke_tests runtests.py 10 | deps = 11 | django-22: Django==2.2 12 | django-32: Django==3.2 13 | -r{toxinidir}/requirements_test.txt 14 | basepython = 15 | py: python 16 | py37: python3.7 17 | py38: python3.8 18 | py39: python3.9 19 | py310: python3.10 20 | -------------------------------------------------------------------------------- /tests/decorators.py: -------------------------------------------------------------------------------- 1 | from functools import wraps 2 | 3 | 4 | def decorator_without_functools_wraps(f): 5 | def wrapper(request, *args, **kwargs): 6 | if not request.user.is_superuser: 7 | raise Exception 8 | return f(request, *args, **kwargs) 9 | return wrapper 10 | 11 | 12 | def decorator_with_functools_wraps(f): 13 | @wraps(f) 14 | def wrapper(request, *args, **kwargs): 15 | if not request.user.is_superuser: 16 | raise Exception 17 | return f(request, *args, **kwargs) 18 | return wrapper 19 | -------------------------------------------------------------------------------- /tests/app/views.py: -------------------------------------------------------------------------------- 1 | from django.http import HttpResponse 2 | 3 | from ..decorators import decorator_with_functools_wraps, decorator_without_functools_wraps 4 | 5 | 6 | def app_view(request, parameter): 7 | return HttpResponse() 8 | 9 | 10 | @decorator_with_functools_wraps 11 | def view_with_decorator_with_wraps(request): 12 | return HttpResponse() 13 | 14 | 15 | @decorator_without_functools_wraps 16 | def view_with_decorator_without_wraps(request): 17 | return HttpResponse() 18 | 19 | 20 | def skipped_app_view(request): 21 | return HttpResponse() 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | __pycache__ 3 | 4 | # C extensions 5 | *.so 6 | 7 | # Packages 8 | *.egg 9 | *.egg-info 10 | dist 11 | build 12 | eggs 13 | parts 14 | bin 15 | var 16 | sdist 17 | develop-eggs 18 | .installed.cfg 19 | lib 20 | lib64 21 | .pypirc 22 | 23 | # Installer logs 24 | pip-log.txt 25 | 26 | # Unit test / coverage reports 27 | .coverage 28 | .tox 29 | nosetests.xml 30 | htmlcov 31 | 32 | # Translations 33 | *.mo 34 | 35 | # Mr Developer 36 | .mr.developer.cfg 37 | .project 38 | .pydevproject 39 | 40 | # Pycharm/Intellij 41 | .idea 42 | 43 | # Complexity 44 | output/*.html 45 | output/*/index.html 46 | 47 | # Sphinx 48 | docs/_build 49 | -------------------------------------------------------------------------------- /runtests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 3 | from __future__ import unicode_literals, absolute_import 4 | 5 | import os 6 | import sys 7 | 8 | import django 9 | from django.conf import settings 10 | from django.test.utils import get_runner 11 | 12 | 13 | def run_tests(*test_args): 14 | if not test_args: 15 | test_args = ['tests'] 16 | 17 | os.environ['DJANGO_SETTINGS_MODULE'] = 'tests.settings' 18 | django.setup() 19 | TestRunner = get_runner(settings) 20 | test_runner = TestRunner() 21 | failures = test_runner.run_tests(test_args) 22 | sys.exit(bool(failures)) 23 | 24 | 25 | if __name__ == '__main__': 26 | run_tests(*sys.argv[1:]) 27 | -------------------------------------------------------------------------------- /django_smoke_tests/templates/django_smoke_tests/base.html: -------------------------------------------------------------------------------- 1 | 2 | {% comment %} 3 | As the developer of this package, don't place anything here if you can help it 4 | since this allows developers to have interoperability between your template 5 | structure and their own. 6 | 7 | Example: Developer melding the 2SoD pattern to fit inside with another pattern:: 8 | 9 | {% extends "base.html" %} 10 | {% load static %} 11 | 12 | 13 | {% block extra_js %} 14 | 15 | 16 | {% block javascript %} 17 | 18 | {% endblock javascript %} 19 | 20 | {% endblock extra_js %} 21 | {% endcomment %} 22 | -------------------------------------------------------------------------------- /tests/helpers.py: -------------------------------------------------------------------------------- 1 | import random 2 | import string 3 | import sys 4 | from contextlib import contextmanager 5 | try: 6 | # python2 7 | from StringIO import StringIO 8 | except ImportError: 9 | # python3 10 | from io import StringIO 11 | 12 | 13 | @contextmanager 14 | def captured_output(): 15 | """ 16 | Context manager that allows to catch printed output. 17 | """ 18 | new_out, new_err = StringIO(), StringIO() 19 | old_out, old_err = sys.stdout, sys.stderr 20 | try: 21 | sys.stdout, sys.stderr = new_out, new_err 22 | yield sys.stdout, sys.stderr 23 | finally: 24 | sys.stdout, sys.stderr = old_out, old_err 25 | 26 | 27 | def create_random_string(length=5): 28 | return ''.join(random.choice(string.ascii_lowercase) for _ in range(length)) 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | MIT License 3 | 4 | Copyright (c) 2017, Kamil Kijak 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 7 | 8 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 9 | 10 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /tests/app/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import re_path 2 | 3 | from .views import ( 4 | app_view, 5 | skipped_app_view, 6 | view_with_decorator_with_wraps, 7 | view_with_decorator_without_wraps 8 | ) 9 | 10 | # TODO: add tests using path() 11 | 12 | url_patterns_with_decorator_with_wraps = [ 13 | re_path( 14 | r'^decorator-with-wraps/$', view_with_decorator_with_wraps, 15 | name='decorator_with_wraps' 16 | ), 17 | ] 18 | 19 | # views with custom decorators without @functools.wraps are not supported when specifying app_name 20 | url_patterns_with_decorator_without_wraps = [ 21 | re_path( 22 | r'^decorator-without-wraps/$', view_with_decorator_without_wraps, 23 | name='decorator_without_wraps' 24 | ), 25 | ] 26 | 27 | skipped_app_url_patterns = [ 28 | re_path(r'^skipped-app-endpoint/$', skipped_app_view, name='skipped_app_endpoint'), 29 | ] 30 | 31 | urlpatterns = [ 32 | re_path(r'^/(?P.+)?', app_view, name='app_view'), 33 | ] + url_patterns_with_decorator_with_wraps + url_patterns_with_decorator_without_wraps + \ 34 | skipped_app_url_patterns 35 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 2 | from __future__ import unicode_literals, absolute_import 3 | 4 | import django 5 | 6 | DEBUG = True 7 | USE_TZ = True 8 | 9 | # SECURITY WARNING: keep the secret key used in production secret! 10 | SECRET_KEY = "llllllllllllllllllllllllllllllllllllllllllllllllll" 11 | 12 | DATABASES = { 13 | "default": { 14 | "ENGINE": "django.db.backends.sqlite3", 15 | "NAME": ":memory:", 16 | } 17 | } 18 | 19 | ROOT_URLCONF = "tests.urls" 20 | 21 | INSTALLED_APPS = [ 22 | "django.contrib.auth", 23 | "django.contrib.contenttypes", 24 | "django.contrib.sessions", 25 | "django.contrib.sites", 26 | "django_smoke_tests", 27 | 28 | 'rest_framework', 29 | 'tests.another_app', 30 | 'tests.app', 31 | ] 32 | 33 | SITE_ID = 1 34 | 35 | AUTH_USER_MODEL = 'app.CustomUserModel' 36 | 37 | SKIP_SMOKE_TESTS = ( 38 | 'skipped_endpoint', 39 | 'skipped_app_endpoint', 40 | 'another_app_namespace:skipped_endpoint_by_namespace_in_another_app', 41 | 'another_app:skipped_endpoint_by_app_name_in_another_app', 42 | ) 43 | 44 | MIDDLEWARE = ( 45 | 'django.contrib.sessions.middleware.SessionMiddleware', 46 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 47 | ) 48 | -------------------------------------------------------------------------------- /tests/views.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.decorators import login_required 2 | from django.http import HttpResponse 3 | from rest_framework.decorators import permission_classes 4 | from rest_framework.permissions import IsAuthenticated 5 | from rest_framework.response import Response 6 | from rest_framework.status import HTTP_201_CREATED, HTTP_204_NO_CONTENT 7 | from rest_framework.views import APIView 8 | from rest_framework.viewsets import ViewSet 9 | 10 | 11 | def simple_method_view(request, parameter=None): 12 | return HttpResponse() 13 | 14 | 15 | @permission_classes((IsAuthenticated,)) 16 | def view_with_drf_auth(request): 17 | return HttpResponse() 18 | 19 | 20 | class ViewWithDRFAuth(APIView): 21 | permission_classes = (IsAuthenticated,) 22 | 23 | def get(self, *args, **kwargs): 24 | return Response() 25 | 26 | 27 | @login_required 28 | def view_with_django_auth(request): 29 | return HttpResponse() 30 | 31 | 32 | class SimpleViewSet(ViewSet): 33 | 34 | def list(self, request): 35 | return Response([]) 36 | 37 | def create(self, request): 38 | return Response(status=HTTP_201_CREATED) 39 | 40 | def retrieve(self, request, pk=None): 41 | return Response() 42 | 43 | def update(self, request, pk=None): 44 | return Response() 45 | 46 | def destroy(self, request, pk=None): 47 | return Response(status=HTTP_204_NO_CONTENT) 48 | 49 | 50 | def skipped_view(request): 51 | return HttpResponse() 52 | -------------------------------------------------------------------------------- /.github/workflows/actions-run-tests.yml: -------------------------------------------------------------------------------- 1 | name: Run tests 2 | on: [push] 3 | 4 | jobs: 5 | Run-Tests: 6 | runs-on: '${{ matrix.os }}' 7 | strategy: 8 | matrix: 9 | include: 10 | - os: ubuntu-18.04 11 | python-version: '3.7' 12 | TOX_ENV: py-django-22 13 | - os: ubuntu-18.04 14 | python-version: '3.7' 15 | TOX_ENV: py-django-32 16 | - os: ubuntu-18.04 17 | python-version: '3.8' 18 | TOX_ENV: py-django-22 19 | - os: ubuntu-18.04 20 | python-version: '3.8' 21 | TOX_ENV: py-django-32 22 | - os: ubuntu-18.04 23 | python-version: '3.9' 24 | TOX_ENV: py-django-22 25 | - os: ubuntu-18.04 26 | python-version: '3.9' 27 | TOX_ENV: py-django-32 28 | - os: ubuntu-18.04 29 | python-version: '3.10' 30 | TOX_ENV: py-django-22 31 | - os: ubuntu-18.04 32 | python-version: '3.10' 33 | TOX_ENV: py-django-32 34 | steps: 35 | - run: echo "🎉 The job was automatically triggered by a ${{ github.event_name }} event." 36 | - name: 'Set up Python ${{ matrix.python-version }}' 37 | uses: actions/setup-python@v2 38 | with: 39 | python-version: '${{ matrix.python-version }}' 40 | - uses: actions/checkout@v2 41 | - run: pip install -r requirements_test.txt 42 | - run: tox -e $TOX_ENV 43 | env: 44 | TOX_ENV: '${{ matrix.TOX_ENV }}' 45 | - run: codecov -e TOX_ENV 46 | - run: echo "🍏 This job's status is ${{ job.status }}." 47 | -------------------------------------------------------------------------------- /django_smoke_tests/tests.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import get_user_model 2 | from django.core.management import call_command 3 | from django.test import TestCase 4 | 5 | 6 | class SmokeTests(TestCase): 7 | 8 | @classmethod 9 | def setUpTestData(cls): 10 | fixture_path = getattr(cls, 'fixture_path', None) 11 | if fixture_path: 12 | call_command('loaddata', fixture_path) 13 | 14 | @classmethod 15 | def setUpClass(cls): 16 | super(SmokeTests, cls).setUpClass() 17 | cls.smoke_user_credentials = { 18 | 'username': 'smoke_superuser', 19 | 'email': 'smoke@test.com', 20 | 'password': 'smoke_password' 21 | } 22 | cls.smoke_user = get_user_model().objects.create_superuser( 23 | cls.smoke_user_credentials['username'], 24 | cls.smoke_user_credentials['email'], 25 | cls.smoke_user_credentials['password'], 26 | ) 27 | 28 | def setUp(self): 29 | super(SmokeTests, self).setUp() 30 | try: 31 | self.client.force_login(self.smoke_user) # faster than regular logging 32 | except AttributeError: 33 | # force_login available from Django 1.9 34 | self.client.login( 35 | usernme=self.smoke_user_credentials['username'], 36 | password=self.smoke_user_credentials['password'] 37 | ) 38 | 39 | def fail_test(self, url, http_method, response): 40 | fail_msg = ( 41 | '\nSMOKE TEST FAILED' 42 | '\nURL: {}' 43 | '\nHTTP METHOD: {}' 44 | '\nSTATUS CODE: {}' 45 | ).format(url, http_method, response.status_code) 46 | self.fail(fail_msg) 47 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | ## 2.1.0 - 09/04/2022 4 | - improve how allowed and disallowed status codes are handled, improve a default rule 5 | 6 | ## 2.0.0 - 07/04/2022 7 | - drop support for Django 1.x, leave support for 2.2 LTS, 3.2 LTS only 8 | - drop support for Python 2.x 9 | - fix [#15] - incorrect URLs generated for django2 style path() with parameters 10 | - fix [#17] - support namespaces in SKIP_SMOKE_TESTS 11 | 12 | ## 1.0.1 - 07/08/2018 13 | - fix documentation 14 | 15 | ## 1.0.0 - 06/08/2018 16 | - add support for Django 2.1 17 | 18 | ## 0.4.1 - 12/02/2018 19 | - fix [#5] - use `get_user_model()` 20 | 21 | ## 0.4.0 - 22/01/2018 22 | - add `--fixture` parameter 23 | 24 | ## 0.3.0 - 21/01/2018 25 | - add parameters: 26 | * `--settings` 27 | * `--configuration` 28 | * `--no-migrations` 29 | 30 | ## 0.2.2 - 16/01/2018 31 | - improve skipping smoke tests 32 | 33 | ## 0.2.1 - 16/01/2018 34 | - fix [#2] - wrong exception handling 35 | 36 | ## 0.2.0 - 14/01/2018 37 | - add support for Django 2.0 38 | - add `--get-only` parameter 39 | 40 | ## 0.1.4 - 09/12/2017 41 | - improve README 42 | 43 | ## 0.1.3 - 04/12/2017 44 | - fix overriding Django version by installing the package 45 | 46 | ## 0.1.2 - 04/12/2017 47 | - remove support for Django 2.0 48 | 49 | ## 0.1.1 - 03/12/2017 50 | - add parameters: 51 | * `app_names` 52 | * `--http-methods` 53 | * `--allow-status-code` 54 | * `--disallow-status-codes` 55 | * `--no-db` 56 | - add setting `SKIP_SMOKE_TESTS` 57 | 58 | [#2]: https://github.com/kamilkijak/django-smoke-tests/issues/2 59 | [#5]: https://github.com/kamilkijak/django-smoke-tests/issues/5 60 | [#15]: https://github.com/kamilkijak/django-smoke-tests/issues/15 61 | [#17]: https://github.com/kamilkijak/django-smoke-tests/issues/17 62 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: clean-pyc clean-build docs help 2 | .DEFAULT_GOAL := help 3 | define BROWSER_PYSCRIPT 4 | import os, webbrowser, sys 5 | try: 6 | from urllib import pathname2url 7 | except: 8 | from urllib.request import pathname2url 9 | 10 | webbrowser.open("file://" + pathname2url(os.path.abspath(sys.argv[1]))) 11 | endef 12 | export BROWSER_PYSCRIPT 13 | BROWSER := python -c "$$BROWSER_PYSCRIPT" 14 | 15 | help: 16 | @perl -nle'print $& if m{^[a-zA-Z_-]+:.*?## .*$$}' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-25s\033[0m %s\n", $$1, $$2}' 17 | 18 | clean: clean-build clean-pyc 19 | 20 | clean-build: ## remove build artifacts 21 | rm -fr build/ 22 | rm -fr dist/ 23 | rm -fr *.egg-info 24 | 25 | clean-pyc: ## remove Python file artifacts 26 | find . -name '*.pyc' -exec rm -f {} + 27 | find . -name '*.pyo' -exec rm -f {} + 28 | find . -name '*~' -exec rm -f {} + 29 | 30 | lint: ## check style with flake8 31 | flake8 django_smoke_tests tests 32 | 33 | test: ## run tests quickly with the default Python 34 | python runtests.py tests 35 | 36 | test-all: ## run tests on every Python version with tox 37 | tox 38 | 39 | coverage: ## check code coverage quickly with the default Python 40 | coverage run --source django_smoke_tests runtests.py tests 41 | coverage report -m 42 | coverage html 43 | open htmlcov/index.html 44 | 45 | docs: ## generate Sphinx HTML documentation, including API docs 46 | rm -f docs/django-smoke-tests.rst 47 | rm -f docs/modules.rst 48 | sphinx-apidoc -o docs/ django_smoke_tests 49 | $(MAKE) -C docs clean 50 | $(MAKE) -C docs html 51 | $(BROWSER) docs/_build/html/index.html 52 | 53 | release: clean ## package and upload a release 54 | python setup.py sdist upload 55 | python setup.py bdist_wheel upload 56 | 57 | sdist: clean ## package 58 | python setup.py sdist 59 | ls -l dist 60 | -------------------------------------------------------------------------------- /tests/urls.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals, absolute_import 3 | 4 | from django.conf.urls import url, include 5 | from django.urls import path 6 | from django.views.generic import RedirectView 7 | from rest_framework.routers import DefaultRouter 8 | 9 | from .views import ( 10 | skipped_view, simple_method_view, view_with_django_auth, view_with_drf_auth, SimpleViewSet, 11 | ViewWithDRFAuth 12 | ) 13 | 14 | 15 | # kept separately, because they are used in tests 16 | url_patterns_with_authentication = [ 17 | url(r'^test-drf-auth/$', view_with_drf_auth, name='endpoint_with_drf_authentication'), 18 | url( 19 | r'^test-drf-auth-class/$', ViewWithDRFAuth.as_view(), 20 | name='endpoint_with_drf_authentication_class' 21 | ), 22 | url(r'^test-django-auth/$', view_with_django_auth, name='endpoint_with_django_authentication'), 23 | ] 24 | 25 | skipped_url_patterns = [ 26 | url(r'^skipped-endpoint/$', skipped_view, name='skipped_endpoint'), 27 | ] 28 | 29 | urlpatterns = [ 30 | url(r'^$', RedirectView.as_view(url='/', permanent=True), name='root_url'), 31 | url(r'^test/$', RedirectView.as_view(url='/', permanent=True), name='basic_endpoint'), 32 | url(r'^test-without-name/$', RedirectView.as_view(url='/', permanent=True)), 33 | url( 34 | r'^test-with-parameter/(?P[0-9]+)$', simple_method_view, 35 | name='endpoint_with_parameter' 36 | ), 37 | 38 | # fixed edge cases 39 | path('app_urls/', include('tests.app.urls')), 40 | path('another_app_urls/', include('tests.another_app.urls', namespace='another_app_namespace')), 41 | 42 | # using path() 43 | path( 44 | 'test-with-new-style-parameter/', simple_method_view, 45 | name='endpoint_with_new_style_parameter' 46 | ), 47 | 48 | path('admin/users//delete/', simple_method_view, name='delete_user') 49 | 50 | ] + url_patterns_with_authentication + skipped_url_patterns 51 | 52 | router = DefaultRouter() 53 | router.register(r'view-set', SimpleViewSet, basename='view-set') 54 | 55 | urlpatterns += router.urls 56 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | import os 4 | import re 5 | import sys 6 | 7 | try: 8 | from setuptools import setup 9 | except ImportError: 10 | from distutils.core import setup 11 | 12 | 13 | def get_version(*file_paths): 14 | """Retrieves the version from django_smoke_tests/__init__.py""" 15 | filename = os.path.join(os.path.dirname(__file__), *file_paths) 16 | version_file = open(filename).read() 17 | version_match = re.search(r"^__version__ = ['\"]([^'\"]*)['\"]", 18 | version_file, re.M) 19 | if version_match: 20 | return version_match.group(1) 21 | raise RuntimeError('Unable to find version string.') 22 | 23 | 24 | version = get_version("django_smoke_tests", "__init__.py") 25 | 26 | 27 | if sys.argv[-1] == 'publish': 28 | try: 29 | import wheel 30 | print("Wheel version: ", wheel.__version__) 31 | except ImportError: 32 | print('Wheel library missing. Please run "pip install wheel"') 33 | sys.exit() 34 | os.system('python setup.py sdist upload') 35 | os.system('python setup.py bdist_wheel upload') 36 | sys.exit() 37 | 38 | if sys.argv[-1] == 'tag': 39 | print("Tagging the version on git:") 40 | os.system("git tag -a %s -m 'version %s'" % (version, version)) 41 | os.system("git push --tags") 42 | sys.exit() 43 | 44 | readme = open('README.rst').read() 45 | 46 | _read = lambda f: open( 47 | os.path.join(os.path.dirname(__file__), f)).read() if os.path.exists(f) else '' 48 | 49 | install_requires = [ 50 | l.replace('==', '>=') for l in _read('requirements.txt').split('\n') 51 | if l and not l.startswith('#') and not l.startswith('-')] 52 | 53 | setup( 54 | name='django-smoke-tests', 55 | version=version, 56 | description="""Automatic smoke tests for Django project.""", 57 | long_description=readme, 58 | author='Kamil Kijak', 59 | author_email='kamilkijak@gmail.com', 60 | url='https://github.com/kamilkijak/django-smoke-tests', 61 | packages=[ 62 | 'django_smoke_tests', 63 | ], 64 | include_package_data=True, 65 | install_requires=install_requires, 66 | license="MIT", 67 | zip_safe=False, 68 | keywords=['django-smoke-tests', 'test', 'smoke'], 69 | classifiers=[ 70 | 'Development Status :: 3 - Alpha', 71 | 'Framework :: Django', 72 | 'Framework :: Django :: 2.2', 73 | 'Framework :: Django :: 3.2', 74 | 'Intended Audience :: Developers', 75 | 'License :: OSI Approved :: BSD License', 76 | 'Natural Language :: English', 77 | 'Programming Language :: Python :: 3', 78 | 'Programming Language :: Python :: 3.7', 79 | 'Programming Language :: Python :: 3.8', 80 | 'Programming Language :: Python :: 3.9', 81 | 'Programming Language :: Python :: 3.10', 82 | ], 83 | ) 84 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | ============ 2 | Contributing 3 | ============ 4 | 5 | Contributions are welcome, and they are greatly appreciated! Every 6 | little bit helps, and credit will always be given. 7 | 8 | You can contribute in many ways: 9 | 10 | Types of Contributions 11 | ---------------------- 12 | 13 | Report Bugs 14 | ~~~~~~~~~~~ 15 | 16 | Report bugs at https://github.com/kamilkijak/django-smoke-tests/issues. 17 | 18 | If you are reporting a bug, please include: 19 | 20 | * Your operating system name and version. 21 | * Any details about your local setup that might be helpful in troubleshooting. 22 | * Detailed steps to reproduce the bug. 23 | 24 | Fix Bugs 25 | ~~~~~~~~ 26 | 27 | Look through the GitHub issues for bugs. Anything tagged with "bug" 28 | is open to whoever wants to implement it. 29 | 30 | Implement Features 31 | ~~~~~~~~~~~~~~~~~~ 32 | 33 | Look through the GitHub issues for features. Anything tagged with "feature" 34 | is open to whoever wants to implement it. 35 | 36 | Write Documentation 37 | ~~~~~~~~~~~~~~~~~~~ 38 | 39 | django-smoke-tests could always use more documentation, whether as part of the 40 | official django-smoke-tests docs, in docstrings, or even on the web in blog posts, 41 | articles, and such. 42 | 43 | Submit Feedback 44 | ~~~~~~~~~~~~~~~ 45 | 46 | The best way to send feedback is to file an issue at https://github.com/kamilkijak/django-smoke-tests/issues. 47 | 48 | If you are proposing a feature: 49 | 50 | * Explain in detail how it would work. 51 | * Keep the scope as narrow as possible, to make it easier to implement. 52 | * Remember that this is a volunteer-driven project, and that contributions 53 | are welcome :) 54 | 55 | Get Started! 56 | ------------ 57 | 58 | Ready to contribute? Here's how to set up `django-smoke-tests` for local development. 59 | 60 | 1. Fork the `django-smoke-tests` repo on GitHub. 61 | 2. Clone your fork locally:: 62 | 63 | $ git clone git@github.com:your_name_here/django-smoke-tests.git 64 | 65 | 3. Install your local copy into a virtualenv. Assuming you have virtualenvwrapper installed, this is how you set up your fork for local development:: 66 | 67 | $ mkvirtualenv django-smoke-tests 68 | $ cd django-smoke-tests/ 69 | $ python setup.py develop 70 | 71 | 4. Create a branch for local development:: 72 | 73 | $ git checkout -b name-of-your-bugfix-or-feature 74 | 75 | Now you can make your changes locally. 76 | 77 | 5. When you're done making changes, check that your changes pass flake8 and the 78 | tests, including testing other Python versions with tox:: 79 | 80 | $ flake8 django_smoke_tests tests 81 | $ python setup.py test 82 | $ tox 83 | 84 | To get flake8 and tox, just pip install them into your virtualenv. 85 | 86 | 6. Commit your changes and push your branch to GitHub:: 87 | 88 | $ git add . 89 | $ git commit -m "Your detailed description of your changes." 90 | $ git push origin name-of-your-bugfix-or-feature 91 | 92 | 7. Submit a pull request through the GitHub website. 93 | 94 | Pull Request Guidelines 95 | ----------------------- 96 | 97 | Before you submit a pull request, check that it meets these guidelines: 98 | 99 | 1. The pull request should include tests. 100 | 2. If the pull request adds functionality, the docs should be updated. Put 101 | your new functionality into a function with a docstring, and add the 102 | feature to the list in README.rst. 103 | 3. The pull request should work for Python 3.7, 3.8, 3.9 and 3.10. Check 104 | https://github.com/kamilkijak/django-smoke-tests/actions 105 | and make sure that the tests pass for all supported Python versions. 106 | 107 | Tips 108 | ---- 109 | 110 | To run a subset of tests:: 111 | 112 | $ python -m unittest tests.test_django_smoke_tests 113 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ================== 2 | django-smoke-tests 3 | ================== 4 | 5 | .. image:: https://badge.fury.io/py/django-smoke-tests.svg 6 | :target: https://badge.fury.io/py/django-smoke-tests 7 | 8 | .. image:: https://github.com/kamilkijak/django-smoke-tests/actions/workflows/actions-run-tests.yml/badge.svg?branch=master 9 | :target: https://github.com/kamilkijak/django-smoke-tests/actions/workflows/actions-run-tests.yml 10 | 11 | .. image:: https://codecov.io/gh/kamilkijak/django-smoke-tests/branch/master/graph/badge.svg 12 | :target: https://codecov.io/gh/kamilkijak/django-smoke-tests 13 | 14 | Django command that finds all endpoints in project, executes HTTP requests against them and checks if there are any unexpected responses. 15 | 16 | .. image:: https://i.imgur.com/cPK0y3W.gif 17 | 18 | .. _contents: 19 | 20 | .. contents:: 21 | 22 | Requirements 23 | ------------ 24 | 25 | - Python (3.7, 3.8, 3.9, 3.10) 26 | - Django (2.2 LTS, 3.2 LTS) 27 | 28 | Installation 29 | ------------ 30 | Install using pip:: 31 | 32 | pip install django-smoke-tests 33 | 34 | 35 | Add it to your ``INSTALLED_APPS``: 36 | 37 | .. code-block:: python 38 | 39 | INSTALLED_APPS = ( 40 | ... 41 | 'django_smoke_tests', 42 | ... 43 | ) 44 | 45 | 46 | Quickstart 47 | ---------- 48 | Execute smoke tests for the whole project:: 49 | 50 | python manage.py smoke_tests 51 | 52 | 53 | Usage 54 | ----- 55 | 56 | Parameters 57 | ~~~~~~~~~~ 58 | :: 59 | 60 | $ python manage.py smoke_tests --help 61 | usage: manage.py smoke_tests [-h] [--http-methods HTTP_METHODS] 62 | [--allow-status-codes ALLOW_STATUS_CODES] 63 | [--disallow-status-codes DISALLOW_STATUS_CODES] 64 | [--settings SETTINGS] 65 | [--configuration CONFIGURATION] 66 | [--fixture FIXTURE] [--no-migrations] [--no-db] 67 | [app_names] 68 | 69 | Smoke tests for Django endpoints. 70 | 71 | positional arguments: 72 | app_names names of apps to test 73 | 74 | optional arguments: 75 | -h, --help show this help message and exit 76 | --http-methods HTTP_METHODS 77 | comma separated HTTP methods that will be executed for 78 | all endpoints, eg. GET,POST,DELETE 79 | [default: GET,POST,PUT,DELETE] 80 | -g, --get-only shortcut for --http-methods GET 81 | --allow-status-codes ALLOW_STATUS_CODES 82 | comma separated HTTP status codes that will be 83 | considered as success responses, eg. 200,201,204; 84 | 404 is allowed by default for detail URLs (paths with parameters) 85 | [default: 200,201,301,302,304,405] 86 | --disallow-status-codes DISALLOW_STATUS_CODES 87 | comma separated HTTP status codes that will be 88 | considered as fail responses, eg. 404,500 89 | --settings SETTINGS path to the Django settings module, eg. 90 | myproject.settings 91 | --configuration CONFIGURATION 92 | name of the configuration class to load, eg. 93 | Development 94 | --fixture FIXTURE Django fixture JSON file to be loaded before executing 95 | smoke tests 96 | --no-migrations flag for skipping migrations, database will be created 97 | directly from models 98 | --no-db flag for skipping database creation 99 | 100 | 101 | Skipping tests 102 | ~~~~~~~~~~~~~~ 103 | To skip tests for specific URLs add ``SKIP_SMOKE_TESTS`` option in your settings. 104 | 105 | This setting should contain list of URL pattern names. 106 | 107 | .. code-block:: python 108 | 109 | SKIP_SMOKE_TESTS = ( 110 | 'all-astronauts', # to skip url(r'^astronauts/', AllAstronauts.as_view(), name='all-astronauts') 111 | 'missions:all-launches', # to skip 'all-launches' from 'missions' app 112 | ) 113 | 114 | 115 | Reporting bugs 116 | -------------- 117 | If you face any problems please report them to the issue tracker at https://github.com/kamilkijak/django-smoke-tests/issues 118 | 119 | Contributing 120 | ------------- 121 | 122 | Running Tests 123 | ~~~~~~~~~~~~~~ 124 | Does the code actually work? 125 | 126 | :: 127 | 128 | source /bin/activate 129 | (myenv) $ pip install tox 130 | (myenv) $ tox 131 | 132 | Credits 133 | ------- 134 | 135 | Tools used in rendering this package: 136 | 137 | * Cookiecutter_ 138 | * `cookiecutter-djangopackage`_ 139 | 140 | .. _Cookiecutter: https://github.com/audreyr/cookiecutter 141 | .. _`cookiecutter-djangopackage`: https://github.com/pydanny/cookiecutter-djangopackage 142 | -------------------------------------------------------------------------------- /django_smoke_tests/management/commands/smoke_tests.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import os 3 | 4 | from django.core.management import BaseCommand, CommandParser 5 | from django.core.management.base import CommandError 6 | 7 | from ...generator import SmokeTestsGenerator 8 | 9 | 10 | class Command(BaseCommand): 11 | help = "Smoke tests for Django endpoints." 12 | 13 | def create_parser(self, prog_name, subcommand, **kwargs): 14 | """ 15 | Override in order to skip default parameters like verbosity, version, etc. 16 | """ 17 | def _create_parser(*args): 18 | return CommandParser( 19 | *args, 20 | prog="%s %s" % (os.path.basename(prog_name), subcommand), 21 | description=self.help or None, 22 | **kwargs, 23 | ) 24 | parser = _create_parser() 25 | 26 | # create hidden options (required by BaseCommand) 27 | parser.add_argument('--force-color', help=argparse.SUPPRESS) 28 | parser.add_argument('--no-color', help=argparse.SUPPRESS) 29 | parser.add_argument('--pythonpath', help=argparse.SUPPRESS) 30 | parser.add_argument('--skip-checks', help=argparse.SUPPRESS) 31 | parser.add_argument('--traceback', help=argparse.SUPPRESS) 32 | self.add_arguments(parser) 33 | return parser 34 | 35 | def add_arguments(self, parser): 36 | methods_group = parser.add_mutually_exclusive_group() 37 | methods_group.add_argument( 38 | '--http-methods', 39 | default=None, 40 | type=str, 41 | help='comma separated HTTP methods that will be executed for all endpoints, ' 42 | 'eg. GET,POST,DELETE [default: GET,POST,PUT,DELETE]' 43 | ) 44 | methods_group.add_argument( 45 | '-g', '--get-only', 46 | action='store_true', 47 | default=False, 48 | dest='get_only', 49 | help='shortcut for --http-methods GET' 50 | ) 51 | parser.add_argument( 52 | '--allow-status-codes', 53 | default=None, 54 | type=str, 55 | help='comma separated HTTP status codes that will be considered as success responses, ' 56 | 'eg. 200,201,204 [default: 200,201,301,302,304,405]' 57 | ) 58 | parser.add_argument( 59 | '--disallow-status-codes', 60 | default=None, 61 | type=str, 62 | help='comma separated HTTP status codes that will be considered as fail responses, ' 63 | 'eg. 404,500' 64 | ) 65 | parser.add_argument( 66 | '--settings', 67 | help=( 68 | 'path to the Django settings module, eg. myproject.settings' 69 | ), 70 | ) 71 | parser.add_argument( 72 | '--configuration', 73 | help=( 74 | 'name of the configuration class to load, e.g. Development' 75 | ), 76 | ) 77 | parser.add_argument( 78 | '--fixture', 79 | help=( 80 | 'Django fixture JSON file to be loaded before executing smoke tests' 81 | ), 82 | ) 83 | parser.add_argument( 84 | '--no-migrations', 85 | dest='no_migrations', 86 | action='store_true', 87 | help='flag for skipping migrations, database will be created directly from models' 88 | ) 89 | parser.set_defaults(no_migrations=False) 90 | parser.add_argument( 91 | '--no-db', 92 | dest='no_db', 93 | action='store_true', 94 | help='flag for skipping database creation' 95 | ) 96 | parser.set_defaults(no_db=False) 97 | parser.add_argument( 98 | 'app_names', 99 | default=None, 100 | nargs='?', 101 | help='names of apps to test', 102 | ) 103 | 104 | def handle(self, *args, **options): 105 | if options.get('get_only'): 106 | methods_to_test = ['GET'] 107 | else: 108 | methods_to_test = self._get_list_from_string(options.get('http_methods')) 109 | allowed_status_codes = self._get_list_from_string(options.get('allow_status_codes')) 110 | disallowed_status_codes = self._get_list_from_string(options.get('disallow_status_codes')) 111 | disable_migrations = options.get('no_migrations') 112 | use_db = not options.get('no_db') 113 | app_names = self._get_list_from_string(options.get('app_names')) 114 | settings_module = options.get('settings') 115 | configuration = options.get('configuration') 116 | fixture_path = options.get('fixture') 117 | 118 | if allowed_status_codes and disallowed_status_codes: 119 | raise CommandError( 120 | 'You can either specify --allow-status-codes or --disallow-status-codes. ' 121 | 'You must not specify both.' 122 | ) 123 | 124 | generator = SmokeTestsGenerator( 125 | http_methods=methods_to_test, 126 | allowed_status_codes=allowed_status_codes, 127 | disallowed_status_codes=disallowed_status_codes, 128 | use_db=use_db, 129 | app_names=app_names, 130 | disable_migrations=disable_migrations, 131 | settings_module=settings_module, 132 | configuration=configuration, 133 | fixture_path=fixture_path, 134 | ) 135 | generator.execute() 136 | 137 | if generator.warnings: 138 | self.stdout.write( 139 | 'Some tests were skipped. Please report on ' 140 | 'https://github.com/kamilkijak/django-smoke-tests/issues.' 141 | ) 142 | self.stdout.write('\n'.join(generator.warnings)) 143 | 144 | @staticmethod 145 | def _get_list_from_string(options): 146 | """ 147 | Transforms comma separated string into a list of those elements. 148 | Transforms strings to ints if they are numbers. 149 | Eg.: 150 | "200,'400','xxx'" => [200, 400, 'xxx'] 151 | """ 152 | if options: 153 | return [ 154 | int(option) if option.isdigit() 155 | else option.strip('/') 156 | for option in options.split(',') 157 | ] 158 | return None 159 | -------------------------------------------------------------------------------- /tests/test_command.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """ 5 | Tests for `smoke_tests` command. 6 | """ 7 | import random 8 | 9 | from django.core.management import call_command, CommandError 10 | from django.test import TestCase 11 | from django.urls import URLPattern 12 | from mock import patch 13 | 14 | from django_smoke_tests.generator import HTTPMethodNotSupported, SmokeTestsGenerator, get_pattern 15 | from django_smoke_tests.tests import SmokeTests 16 | from .urls import urlpatterns 17 | from .helpers import captured_output, create_random_string 18 | 19 | 20 | class TestSmokeTestsCommand(TestCase): 21 | 22 | @classmethod 23 | def setUpClass(cls): 24 | super(TestSmokeTestsCommand, cls).setUpClass() 25 | cls.test_generator = SmokeTestsGenerator() 26 | cls.all_possible_methods = cls.test_generator.SUPPORTED_HTTP_METHODS 27 | 28 | @patch('django_smoke_tests.generator.call_command') 29 | def test_proper_tests_were_created_for_default_methods(self, mocked_call_command): 30 | call_command('smoke_tests') 31 | mocked_call_command.assert_called_once() 32 | 33 | # skip RegexURLResolver (eg. include(app.urls)) 34 | urlpatterns_without_resolvers = [ 35 | url for url in urlpatterns if isinstance(url, URLPattern) 36 | ] 37 | 38 | for url in urlpatterns_without_resolvers: 39 | for method in self.test_generator.SUPPORTED_HTTP_METHODS: 40 | test_name = self.test_generator.create_test_name(method, get_pattern(url)) 41 | self.assertTrue(hasattr(SmokeTests, test_name)) 42 | 43 | @patch('django_smoke_tests.generator.call_command') 44 | def test_only_proper_tests_were_created_for_custom_methods(self, mocked_call_command): 45 | shuffled_methods = random.sample(self.all_possible_methods, len(self.all_possible_methods)) 46 | split_index = random.randint(1, len(shuffled_methods) - 1) 47 | methods_to_call = shuffled_methods[:split_index] 48 | methods_not_called = shuffled_methods[split_index:] 49 | 50 | call_command('smoke_tests', http_methods=','.join(methods_to_call)) 51 | mocked_call_command.assert_called_once() 52 | 53 | # skip RegexURLResolver (eg. include(app.urls)) 54 | urlpatterns_without_resolvers = [ 55 | url for url in urlpatterns if isinstance(url, URLPattern) 56 | ] 57 | 58 | for url in urlpatterns_without_resolvers: 59 | for method in methods_to_call: 60 | test_name = self.test_generator.create_test_name(method, get_pattern(url)) 61 | self.assertTrue(hasattr(SmokeTests, test_name)) 62 | 63 | for method in methods_not_called: 64 | test_name = self.test_generator.create_test_name(method, get_pattern(url)) 65 | self.assertFalse(hasattr(SmokeTests, test_name)) 66 | 67 | @patch('django_smoke_tests.generator.call_command') 68 | def test_raise_an_error_for_not_supported_http_method(self, mocked_call_command): 69 | with self.assertRaises(HTTPMethodNotSupported): 70 | call_command('smoke_tests', http_methods='WRONG') 71 | mocked_call_command.assert_not_called() 72 | 73 | @patch('django_smoke_tests.management.commands.smoke_tests.SmokeTestsGenerator') 74 | def test_right_allowed_status_codes_are_passed_to_test_generator(self, mocked_generator): 75 | mocked_generator.return_value.warnings = [] 76 | allowed_status_codes = '200,201' 77 | call_command('smoke_tests', allow_status_codes=allowed_status_codes) 78 | self.assertEqual( 79 | mocked_generator.call_args[1]['allowed_status_codes'], 80 | [int(code) for code in allowed_status_codes.split(',')] 81 | ) 82 | 83 | @patch('django_smoke_tests.management.commands.smoke_tests.SmokeTestsGenerator') 84 | def test_right_disallowed_status_codes_are_passed_to_test_generator(self, mocked_generator): 85 | mocked_generator.return_value.warnings = [] 86 | disallowed_status_codes = '400,401' 87 | call_command('smoke_tests', disallow_status_codes=disallowed_status_codes) 88 | self.assertEqual( 89 | mocked_generator.call_args[1]['disallowed_status_codes'], 90 | [int(code) for code in disallowed_status_codes.split(',')] 91 | ) 92 | 93 | @patch('django_smoke_tests.management.commands.smoke_tests.SmokeTestsGenerator') 94 | def test_disable_migrations_option_is_passed_to_test_generator(self, mocked_generator): 95 | mocked_generator.return_value.warnings = [] 96 | disable_migrations = True 97 | 98 | call_command('smoke_tests', no_migrations=disable_migrations) 99 | self.assertEqual( 100 | mocked_generator.call_args[1]['disable_migrations'], 101 | disable_migrations 102 | ) 103 | 104 | @patch('django_smoke_tests.management.commands.smoke_tests.SmokeTestsGenerator') 105 | def test_use_db_option_is_passed_to_test_generator(self, mocked_generator): 106 | mocked_generator.return_value.warnings = [] 107 | no_db = True 108 | 109 | call_command('smoke_tests', no_db=no_db) 110 | self.assertEqual( 111 | mocked_generator.call_args[1]['use_db'], 112 | not no_db 113 | ) 114 | 115 | @patch('django_smoke_tests.management.commands.smoke_tests.SmokeTestsGenerator') 116 | def test_app_name_option_is_passed_to_test_generator(self, mocked_generator): 117 | mocked_generator.return_value.warnings = [] 118 | app_name = 'test_app_name' 119 | 120 | call_command('smoke_tests', app_name) 121 | self.assertEqual( 122 | mocked_generator.call_args[1]['app_names'][0], 123 | app_name 124 | ) 125 | 126 | @patch('django_smoke_tests.management.commands.smoke_tests.SmokeTestsGenerator') 127 | def test_multiple_app_names_are_passed_to_test_generator(self, mocked_generator): 128 | mocked_generator.return_value.warnings = [] 129 | first_app = create_random_string() 130 | second_app = create_random_string() 131 | 132 | call_command('smoke_tests', ','.join([first_app, second_app])) 133 | self.assertEqual( 134 | mocked_generator.call_args[1]['app_names'][0], 135 | first_app 136 | ) 137 | self.assertEqual( 138 | mocked_generator.call_args[1]['app_names'][1], 139 | second_app 140 | ) 141 | 142 | @patch('django_smoke_tests.management.commands.smoke_tests.SmokeTestsGenerator') 143 | def test_settings_option_is_passed_to_test_generator(self, mocked_generator): 144 | mocked_generator.return_value.warnings = [] 145 | settings = 'tests.settings' 146 | 147 | call_command('smoke_tests', settings=settings) 148 | self.assertEqual( 149 | mocked_generator.call_args[1]['settings_module'], 150 | settings 151 | ) 152 | 153 | @patch('django_smoke_tests.management.commands.smoke_tests.SmokeTestsGenerator') 154 | def test_configuration_option_is_passed_to_test_generator(self, mocked_generator): 155 | mocked_generator.return_value.warnings = [] 156 | configuration = 'Development' 157 | 158 | call_command('smoke_tests', configuration=configuration) 159 | self.assertEqual( 160 | mocked_generator.call_args[1]['configuration'], 161 | configuration 162 | ) 163 | 164 | def test_error_is_raised_when_both_allowed_and_disallowed_specified(self): 165 | allowed_status_codes = '200,201' 166 | disallowed_status_codes = '400,401' 167 | 168 | with self.assertRaises(CommandError): 169 | call_command( 170 | 'smoke_tests', 171 | allow_status_codes=allowed_status_codes, 172 | disallow_status_codes=disallowed_status_codes 173 | ) 174 | 175 | @patch('django_smoke_tests.generator.call_command') 176 | @patch('django_smoke_tests.management.commands.smoke_tests.SmokeTestsGenerator') 177 | def test_warnings_are_printed(self, mocked_generator, mocked_call_command): 178 | mocked_generator.return_value.warnings = [ 179 | create_random_string() for _ in range(random.randint(1, 10)) 180 | ] 181 | 182 | with captured_output() as (out, err): 183 | call_command('smoke_tests') 184 | 185 | self.assertNotEqual(out.getvalue(), '') 186 | mocked_call_command.assert_not_called() 187 | -------------------------------------------------------------------------------- /django_smoke_tests/generator.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | from django.core.management import call_command 4 | from django.conf import settings 5 | from django.utils.regex_helper import normalize 6 | 7 | from django.urls import URLResolver 8 | from unittest import skip 9 | 10 | from .tests import SmokeTests 11 | 12 | 13 | def get_pattern(url_pattern): 14 | return str(url_pattern.pattern.regex.pattern) 15 | 16 | 17 | class HTTPMethodNotSupported(Exception): 18 | pass 19 | 20 | 21 | class UrlStructureNotSupported(Exception): 22 | pass 23 | 24 | 25 | class AppNotInInstalledApps(Exception): 26 | pass 27 | 28 | 29 | class SmokeTestsGenerator: 30 | SUPPORTED_HTTP_METHODS = ['GET', 'POST', 'PUT', 'DELETE'] 31 | ALLOWED_STATUS_CODES = [200, 201, 301, 302, 304, 405] 32 | DISALLOWED_STATUS_CODES = [500, 501, 502] 33 | 34 | def __init__( 35 | self, http_methods=None, allowed_status_codes=None, disallowed_status_codes=None, 36 | use_db=True, app_names=None, disable_migrations=False, settings_module=None, 37 | configuration=None, fixture_path=None 38 | ): 39 | if http_methods: 40 | self.validate_custom_http_methods(http_methods) 41 | self.methods_to_test = http_methods or self.SUPPORTED_HTTP_METHODS 42 | self.allowed_status_codes = allowed_status_codes 43 | self.disallowed_status_codes = disallowed_status_codes 44 | self.use_db = use_db 45 | self.app_names = self.validate_app_names(app_names) 46 | self.disable_migrations = disable_migrations 47 | self.settings_module = settings_module 48 | self.configuration = configuration 49 | self.fixture_path = fixture_path 50 | self.warnings = [] 51 | 52 | # TODO: consider simplifying the structure below or introducing type hints 53 | self.all_patterns = [] # [(url_pattern, lookup_str, url_name, url_namespace, app_name),] 54 | 55 | def validate_custom_http_methods(self, http_methods): 56 | unsupported_methods = set(http_methods) - set(self.SUPPORTED_HTTP_METHODS) 57 | if unsupported_methods: 58 | raise HTTPMethodNotSupported( 59 | 'Methods {} are not supported'.format(list(unsupported_methods)) 60 | ) 61 | 62 | @staticmethod 63 | def validate_app_names(app_names): 64 | for app_name in app_names or []: 65 | if app_name and app_name not in settings.INSTALLED_APPS: 66 | raise AppNotInInstalledApps(app_name) 67 | return app_names 68 | 69 | def _generate_test(self, url, method, detail_url=False): 70 | def test(self_of_test): 71 | http_method_function = getattr(self_of_test.client, method.lower(), None) 72 | response = http_method_function(url, {}) 73 | additional_status_codes = [404] if detail_url else [] 74 | 75 | # Allowed codes take precedence 76 | if self.allowed_status_codes and ( 77 | response.status_code not in self.allowed_status_codes + additional_status_codes 78 | ): 79 | self_of_test.fail_test(url, method, response=response) 80 | 81 | # Disallowed codes are only considered if allowed codes are not specified 82 | elif not self.allowed_status_codes and self.disallowed_status_codes and ( 83 | response.status_code in self.disallowed_status_codes 84 | ): 85 | self_of_test.fail_test(url, method, response=response) 86 | 87 | # Neither allowed_status_codes nor disallowed_status_codes has been provided, use a default rule 88 | elif not self.allowed_status_codes and not self.disallowed_status_codes: 89 | if response.status_code not in [*self.ALLOWED_STATUS_CODES, *additional_status_codes]: 90 | self_of_test.fail_test(url, method, response=response) 91 | return test 92 | 93 | @staticmethod 94 | def _generate_skipped_test(): 95 | @skip('Not supported') 96 | def test(self_of_test): 97 | pass 98 | 99 | return test 100 | 101 | def execute(self): 102 | self.load_all_endpoints(URLResolver(r'^/', settings.ROOT_URLCONF).url_patterns) 103 | for url_pattern, lookup_str, url_name, url_namespace, app_name in self.all_patterns: 104 | if not self.app_names or self.is_url_inside_specified_app(lookup_str): 105 | self.create_tests_for_endpoint(url_pattern, url_name, url_namespace, app_name) 106 | 107 | if self.disable_migrations: 108 | self._disable_native_migrations() 109 | 110 | self._set_fixture_path() 111 | 112 | call_command_kwargs = self._get_call_command_kwargs() 113 | call_command('test', 'django_smoke_tests', **call_command_kwargs) 114 | 115 | @staticmethod 116 | def _disable_native_migrations(): 117 | from .migrations import DisableMigrations 118 | settings.MIGRATION_MODULES = DisableMigrations() 119 | 120 | def _set_fixture_path(self): 121 | setattr(SmokeTests, 'fixture_path', self.fixture_path) 122 | 123 | def _get_call_command_kwargs(self): 124 | kwargs = {} 125 | 126 | if not self.use_db: 127 | kwargs['testrunner'] = 'django_smoke_tests.runners.NoDbTestRunner' 128 | 129 | if self.settings_module: 130 | kwargs['settings'] = self.settings_module 131 | 132 | if self.configuration: 133 | kwargs['configuration'] = self.configuration 134 | 135 | return kwargs 136 | 137 | def is_url_inside_specified_app(self, lookup_str): 138 | for app_name in self.app_names: 139 | if lookup_str.startswith(app_name): 140 | return True 141 | return False 142 | 143 | def load_all_endpoints(self, url_list, parent_url=None, parent_namespace=None, app_name=None): 144 | for url_pattern in url_list: 145 | if hasattr(url_pattern, 'url_patterns'): 146 | self.load_all_endpoints( 147 | url_pattern.url_patterns, 148 | parent_url + get_pattern(url_pattern) if parent_url else get_pattern(url_pattern), 149 | ':'.join(filter(None, [parent_namespace, url_pattern.namespace])), 150 | url_pattern.app_name, 151 | ) 152 | else: 153 | self.all_patterns.append(( 154 | parent_url + get_pattern(url_pattern) if parent_url else get_pattern(url_pattern), 155 | self.get_lookup_str(url_pattern), 156 | url_pattern.name, 157 | parent_namespace, 158 | app_name, 159 | )) 160 | 161 | @staticmethod 162 | def get_lookup_str(url_pattern): 163 | return url_pattern.lookup_str 164 | 165 | def create_tests_for_endpoint(self, url_pattern, url_name, url_namespace, app_name): 166 | if self.is_endpoint_skipped(url_name, url_namespace, app_name): 167 | self.create_tests_for_http_methods(None, url_pattern, skipped=True) 168 | else: 169 | try: 170 | url_as_str, url_params = self.normalize_url_pattern(url_pattern) 171 | except UrlStructureNotSupported: 172 | self.warnings.append( 173 | 'Test skipped. URL << {} >> could not be parsed.'.format( 174 | url_pattern 175 | )) 176 | self.create_tests_for_http_methods(None, url_pattern, skipped=True) 177 | else: 178 | fake_params = {param: self.create_random_value() for param in url_params} 179 | url = self.create_url(url_as_str, fake_params) 180 | self.create_tests_for_http_methods(url, url_pattern, detail_url=bool(url_params)) 181 | 182 | @staticmethod 183 | def is_endpoint_skipped(url_name, url_namespace, app_name): 184 | try: 185 | if not url_name: 186 | return False 187 | 188 | url_name_with_namespace = f'{url_namespace}:{url_name}' if url_namespace else url_name 189 | if url_name_with_namespace in settings.SKIP_SMOKE_TESTS: 190 | return True 191 | 192 | url_name_with_app_name = f'{app_name}:{url_name}' if app_name else url_name 193 | if url_name_with_app_name in settings.SKIP_SMOKE_TESTS: 194 | return True 195 | 196 | return url_name and url_name in settings.SKIP_SMOKE_TESTS 197 | except AttributeError: 198 | return False 199 | 200 | @staticmethod 201 | def normalize_url_pattern(url_pattern): 202 | normalized = normalize(url_pattern) 203 | 204 | try: 205 | [(url_as_str, url_params)] = normalized 206 | except ValueError: 207 | try: 208 | [(url_as_str_without_param, _), (url_as_str, url_params)] = normalized 209 | except ValueError: 210 | raise UrlStructureNotSupported 211 | 212 | if 'format' in url_params and url_as_str.endswith('.%(format)s'): 213 | # remove optional parameter provided by DRF ViewSet 214 | # eg. /items is provided as /items.%(format)s (/items.json) 215 | url_params.remove('format') 216 | url_as_str = url_as_str[:-len('.%(format)s')] 217 | 218 | return url_as_str, url_params 219 | 220 | @staticmethod 221 | def create_random_value(): 222 | return uuid.uuid4() 223 | 224 | @staticmethod 225 | def create_url(url_as_str, parameters): 226 | url = url_as_str % parameters 227 | return url if url.startswith('/') else '/{}'.format(url) 228 | 229 | def create_tests_for_http_methods(self, url, url_pattern, detail_url=False, skipped=False): 230 | for method in self.methods_to_test: 231 | self.create_test_for_http_method(method, url, url_pattern, detail_url, skipped) 232 | 233 | def create_test_for_http_method( 234 | self, method, url, url_pattern=None, detail_url=False, skipped=False 235 | ): 236 | if skipped: 237 | test = self._generate_skipped_test() 238 | else: 239 | test = self._generate_test(url, method, detail_url) 240 | 241 | if not url_pattern: 242 | url_pattern = url # url and url_pattern are the same when there are no URL parameters 243 | setattr(SmokeTests, self.create_test_name(method, url_pattern), test) 244 | 245 | @staticmethod 246 | def create_test_name(method, url_pattern): 247 | return 'test_smoke_{}_{}'.format(method, url_pattern) 248 | -------------------------------------------------------------------------------- /tests/test_generator.py: -------------------------------------------------------------------------------- 1 | import random 2 | import unittest 3 | from unittest.mock import ANY 4 | 5 | from django.http import HttpResponse 6 | from django.test import TestCase, override_settings 7 | 8 | from django_smoke_tests.migrations import DisableMigrations 9 | 10 | from django.urls import path 11 | from django.views.generic import RedirectView 12 | from mock import patch 13 | from parameterized import parameterized 14 | 15 | from django_smoke_tests.generator import AppNotInInstalledApps, SmokeTestsGenerator, get_pattern 16 | from django_smoke_tests.runners import NoDbTestRunner 17 | from django_smoke_tests.tests import SmokeTests 18 | from tests.another_app.urls import another_app_skipped_urls 19 | 20 | from tests.app.urls import urlpatterns as app_url_patterns 21 | from tests.app.urls import skipped_app_url_patterns 22 | from tests.app.urls import ( 23 | url_patterns_with_decorator_with_wraps, url_patterns_with_decorator_without_wraps 24 | ) 25 | from tests.helpers import captured_output, create_random_string 26 | from tests.urls import url_patterns_with_authentication, skipped_url_patterns 27 | 28 | 29 | SKIPPED_URL_PATTERNS = skipped_url_patterns + skipped_app_url_patterns 30 | 31 | # unpack to use in decorators 32 | SUPPORTED_HTTP_METHODS = SmokeTestsGenerator.SUPPORTED_HTTP_METHODS 33 | URL_PATTERNS_WITH_AUTH = [(url_pattern,) for url_pattern in url_patterns_with_authentication] 34 | 35 | 36 | class DummyStream(object): 37 | """ 38 | Mimics stream for tests executed withing another tests. 39 | Required for catching output of such tests, to not to show it in the console. 40 | """ 41 | 42 | @staticmethod 43 | def write(*args, **kwargs): 44 | pass 45 | 46 | @staticmethod 47 | def flush(): 48 | pass 49 | 50 | 51 | class TestSmokeTestsGenerator(TestCase): 52 | """ 53 | These tests rely on the dummy Django project created under tests/ directory. 54 | TODO: consider splitting these tests into unit (using mocks) and integration tests (using dummy project) 55 | """ 56 | 57 | def setUp(self): 58 | super(TestSmokeTestsGenerator, self).setUp() 59 | self.tests_generator = SmokeTestsGenerator() 60 | 61 | def tearDown(self): 62 | # remove all tests created and added to SmokeTests 63 | tests_created = [attr for attr in vars(SmokeTests) if attr.startswith('test_smoke')] 64 | for test_name in tests_created: 65 | delattr(SmokeTests, test_name) 66 | 67 | @parameterized.expand(SUPPORTED_HTTP_METHODS) 68 | @patch('django_smoke_tests.tests.SmokeTests') 69 | def test_create_test_for_http_method(self, http_method, MockedSmokeTests): 70 | url = '/simple-url' 71 | self.tests_generator.create_test_for_http_method(http_method, url, url) 72 | 73 | expected_test_name = self.tests_generator.create_test_name(http_method, url) 74 | self.assertTrue( 75 | hasattr(MockedSmokeTests, expected_test_name) 76 | ) 77 | 78 | def _execute_smoke_test(self, test_name): 79 | """ 80 | Executes one test inside current test suite. 81 | Be careful as it's kind on inception. 82 | """ 83 | suite = unittest.TestSuite() 84 | suite.addTest(SmokeTests(test_name)) 85 | test_runner = unittest.TextTestRunner(stream=DummyStream).run(suite) 86 | 87 | self.assertEqual(test_runner.errors, []) # errors are never expected 88 | return test_runner.wasSuccessful(), test_runner.failures, test_runner.skipped 89 | 90 | @parameterized.expand(SUPPORTED_HTTP_METHODS) 91 | def test_if_smoke_test_fails_on_allowed_response_status_code(self, http_method): 92 | # use new endpoint to be sure that test was not created in previous tests 93 | endpoint_url = '/{}'.format(create_random_string()) 94 | expected_test_name = self.tests_generator.create_test_name(http_method, endpoint_url) 95 | 96 | self.tests_generator.create_test_for_http_method( 97 | http_method, endpoint_url, detail_url=True 98 | ) # detail_url set to True to allow 404 99 | 100 | # check if test was created and added to test class 101 | self.assertTrue( 102 | hasattr(SmokeTests, expected_test_name) 103 | ) 104 | 105 | is_successful, failures, skipped = self._execute_smoke_test(expected_test_name) 106 | self.assertTrue(is_successful) 107 | self.assertEqual(failures, []) 108 | self.assertEqual(skipped, []) 109 | self.assertEqual(self.tests_generator.warnings, []) 110 | 111 | @parameterized.expand(SUPPORTED_HTTP_METHODS) 112 | def test_if_smoke_test_fails_on_500_response_status_code(self, http_method): 113 | """ 114 | Check if smoke test fails when gets 500 status code from endpoint's response. 115 | """ 116 | with patch('django.test.client.Client.{}'.format(http_method.lower())) as mocked_method: 117 | mocked_method.return_value = HttpResponse(status=500) 118 | 119 | # use new endpoint to be sure that test was not created in previous tests 120 | endpoint_url = '/{}'.format(create_random_string()) 121 | expected_test_name = self.tests_generator.create_test_name(http_method, endpoint_url) 122 | 123 | self.tests_generator.create_test_for_http_method( 124 | http_method, endpoint_url 125 | ) 126 | 127 | # check if test was created and added to test class 128 | self.assertTrue( 129 | hasattr(SmokeTests, expected_test_name) 130 | ) 131 | 132 | is_successful, failures, skipped = self._execute_smoke_test(expected_test_name) 133 | 134 | self.assertFalse(is_successful) 135 | self.assertEqual(len(failures), 1) 136 | self.assertEqual(skipped, []) 137 | self.assertEqual(self.tests_generator.warnings, []) 138 | 139 | @parameterized.expand(SUPPORTED_HTTP_METHODS) 140 | def test_if_smoke_test_passes_on_custom_allowed_response_status_codes(self, http_method): 141 | random_status_code = random.randint(100, 510) 142 | custom_allowed_status_codes = [random_status_code, random_status_code + 1] 143 | tests_generator = SmokeTestsGenerator(allowed_status_codes=custom_allowed_status_codes) 144 | 145 | with patch('django.test.client.Client.{}'.format(http_method.lower())) as mocked_method: 146 | mocked_method.return_value = HttpResponse(status=custom_allowed_status_codes[0]) 147 | 148 | # use new endpoint to be sure that test was not created in previous tests 149 | endpoint_url = '/{}'.format(create_random_string()) 150 | expected_test_name = tests_generator.create_test_name(http_method, endpoint_url) 151 | 152 | tests_generator.create_test_for_http_method( 153 | http_method, endpoint_url 154 | ) 155 | 156 | # check if test was created and added to test class 157 | self.assertTrue( 158 | hasattr(SmokeTests, expected_test_name) 159 | ) 160 | 161 | is_successful, failures, skipped = self._execute_smoke_test(expected_test_name) 162 | 163 | self.assertTrue(is_successful) 164 | self.assertEqual(failures, []) 165 | self.assertEqual(skipped, []) 166 | self.assertEqual(tests_generator.warnings, []) 167 | 168 | @parameterized.expand(SUPPORTED_HTTP_METHODS) 169 | def test_if_smoke_test_fails_on_custom_allowed_response_status_codes(self, http_method): 170 | random_status_code = random.randint(100, 510) 171 | custom_allowed_status_codes = [random_status_code, random_status_code + 1] 172 | tests_generator = SmokeTestsGenerator(allowed_status_codes=custom_allowed_status_codes) 173 | 174 | with patch('django.test.client.Client.{}'.format(http_method.lower())) as mocked_method: 175 | # return different status code 176 | mocked_method.return_value = HttpResponse(status=custom_allowed_status_codes[0] + 10) 177 | 178 | # use new endpoint to be sure that test was not created in previous tests 179 | endpoint_url = '/{}'.format(create_random_string()) 180 | expected_test_name = tests_generator.create_test_name(http_method, endpoint_url) 181 | 182 | tests_generator.create_test_for_http_method( 183 | http_method, endpoint_url 184 | ) 185 | 186 | # check if test was created and added to test class 187 | self.assertTrue( 188 | hasattr(SmokeTests, expected_test_name) 189 | ) 190 | 191 | is_successful, failures, skipped = self._execute_smoke_test(expected_test_name) 192 | 193 | self.assertFalse(is_successful) 194 | self.assertEqual(len(failures), 1) 195 | self.assertEqual(skipped, []) 196 | self.assertEqual(tests_generator.warnings, []) 197 | 198 | @parameterized.expand(SUPPORTED_HTTP_METHODS) 199 | def test_if_smoke_test_passes_on_custom_disallowed_response_status_codes(self, http_method): 200 | random_status_code = random.randint(100, 510) 201 | custom_disallowed_status_codes = [random_status_code, random_status_code + 1] 202 | tests_generator = SmokeTestsGenerator( 203 | disallowed_status_codes=custom_disallowed_status_codes 204 | ) 205 | 206 | with patch('django.test.client.Client.{}'.format(http_method.lower())) as mocked_method: 207 | # return different status code than disallowed 208 | mocked_method.return_value = HttpResponse(status=random_status_code + 10) 209 | 210 | # use new endpoint to be sure that test was not created in previous tests 211 | endpoint_url = '/{}'.format(create_random_string()) 212 | expected_test_name = tests_generator.create_test_name(http_method, endpoint_url) 213 | 214 | tests_generator.create_test_for_http_method( 215 | http_method, endpoint_url 216 | ) 217 | 218 | # check if test was created and added to test class 219 | self.assertTrue( 220 | hasattr(SmokeTests, expected_test_name) 221 | ) 222 | 223 | is_successful, failures, skipped = self._execute_smoke_test(expected_test_name) 224 | 225 | self.assertTrue(is_successful) 226 | self.assertEqual(failures, []) 227 | self.assertEqual(tests_generator.warnings, []) 228 | 229 | @parameterized.expand(SUPPORTED_HTTP_METHODS) 230 | def test_if_smoke_test_fails_on_custom_disallowed_response_status_codes(self, http_method): 231 | random_status_code = random.randint(100, 510) 232 | custom_disallowed_status_codes = [random_status_code, random_status_code + 1] 233 | tests_generator = SmokeTestsGenerator( 234 | disallowed_status_codes=custom_disallowed_status_codes 235 | ) 236 | 237 | with patch('django.test.client.Client.{}'.format(http_method.lower())) as mocked_method: 238 | # return different status code than disallowed 239 | mocked_method.return_value = HttpResponse(status=custom_disallowed_status_codes[0]) 240 | 241 | # use new endpoint to be sure that test was not created in previous tests 242 | endpoint_url = '/{}'.format(create_random_string()) 243 | expected_test_name = tests_generator.create_test_name(http_method, endpoint_url) 244 | 245 | tests_generator.create_test_for_http_method( 246 | http_method, endpoint_url 247 | ) 248 | 249 | # check if test was created and added to test class 250 | self.assertTrue( 251 | hasattr(SmokeTests, expected_test_name) 252 | ) 253 | is_successful, failures, skipped = self._execute_smoke_test(expected_test_name) 254 | 255 | self.assertFalse(is_successful) 256 | self.assertEqual(len(failures), 1) 257 | self.assertEqual(skipped, []) 258 | self.assertEqual(tests_generator.warnings, []) 259 | 260 | @parameterized.expand(SUPPORTED_HTTP_METHODS) 261 | @patch('django_smoke_tests.generator.normalize') 262 | def test_create_skipped_test_for_not_supported_endpoint(self, http_method, mocked_normalize): 263 | mocked_normalize.return_value = [] 264 | tests_generator = SmokeTestsGenerator() 265 | url_pattern = path( 266 | create_random_string(), 267 | RedirectView.as_view(url='/', permanent=False), 268 | name=create_random_string() 269 | ) 270 | expected_test_name = tests_generator.create_test_name( 271 | http_method, get_pattern(url_pattern) 272 | ) 273 | 274 | tests_generator.create_tests_for_endpoint( 275 | get_pattern(url_pattern), url_pattern.name, None, None, 276 | ) 277 | self.assertTrue( 278 | hasattr(SmokeTests, expected_test_name) 279 | ) 280 | 281 | is_successful, failures, skipped = self._execute_smoke_test(expected_test_name) 282 | 283 | self.assertEqual(len(skipped), 1) 284 | self.assertEqual(len(tests_generator.warnings), 1) 285 | 286 | @patch('django_smoke_tests.generator.SmokeTestsGenerator.create_tests_for_http_methods') 287 | @patch('django_smoke_tests.generator.SmokeTestsGenerator.create_random_value') 288 | def test_create_tests_for_endpoint_using_path_with_parameter( 289 | self, mocked_create_random_value, mocked_create_tests_for_http_methods, 290 | ): 291 | # TDD for https://github.com/kamilkijak/django-smoke-tests/issues/15 292 | 293 | mocked_random_uuid = '510a5c25-57e3-49a9-a31c-3e850ea7c8ad' 294 | mocked_create_random_value.return_value = mocked_random_uuid 295 | 296 | # Set up the generator and load one (problematic) path 297 | url_pattern = path('admin/users//delete/', lambda _: None, name='test_endpoint') 298 | tests_generator = SmokeTestsGenerator(http_methods=['GET']) 299 | tests_generator.load_all_endpoints([url_pattern]) 300 | 301 | self.assertEqual(len(tests_generator.all_patterns), 1, 'Should only load 1 endpoint') 302 | 303 | # Create a test for the path loaded above 304 | loaded_url_pattern = tests_generator.all_patterns[0] 305 | tests_generator.create_tests_for_endpoint( # logic from execute() 306 | loaded_url_pattern[0], 307 | loaded_url_pattern[2], 308 | loaded_url_pattern[3], 309 | loaded_url_pattern[4], 310 | ) 311 | 312 | # Check if the test is created against a proper URL 313 | mocked_create_tests_for_http_methods.assert_called_with( 314 | '/admin/users/{}/delete/'.format(mocked_random_uuid), 315 | ANY, 316 | detail_url=True, 317 | ) 318 | 319 | @parameterized.expand(URL_PATTERNS_WITH_AUTH) 320 | @patch('django_smoke_tests.generator.call_command') 321 | def test_if_authentication_is_successful(self, url_pattern_with_auth, mocked_call_command): 322 | tests_generator = SmokeTestsGenerator() 323 | tests_generator.execute() 324 | expected_test_name = self.tests_generator.create_test_name( 325 | 'GET', get_pattern(url_pattern_with_auth) 326 | ) 327 | is_successful, failures, skipped = self._execute_smoke_test(expected_test_name) 328 | mocked_call_command.assert_called_once() 329 | self.assertTrue(is_successful) 330 | self.assertEqual(failures, []) 331 | self.assertEqual(tests_generator.warnings, []) 332 | 333 | @override_settings(MIGRATION_MODULES=DisableMigrations()) 334 | def test_if_test_with_disabled_migrations_is_successful(self): 335 | tests_generator = SmokeTestsGenerator( 336 | allowed_status_codes=[404] # 404 expected with a random string url 337 | ) 338 | http_method = 'GET' 339 | endpoint_url = '/{}'.format(create_random_string()) 340 | expected_test_name = self.tests_generator.create_test_name( 341 | http_method, endpoint_url 342 | ) 343 | tests_generator.create_test_for_http_method( 344 | http_method, endpoint_url 345 | ) 346 | is_successful, failures, skipped = self._execute_smoke_test(expected_test_name) 347 | self.assertTrue(is_successful) 348 | self.assertEqual(failures, []) 349 | self.assertEqual(tests_generator.warnings, []) 350 | 351 | @patch('django_smoke_tests.generator.call_command') 352 | @patch('django_smoke_tests.generator.settings') 353 | def test_if_disable_migrations_option_is_applied(self, mocked_settings, mocked_call_command): 354 | tests_generator = SmokeTestsGenerator(disable_migrations=True) 355 | tests_generator.execute() 356 | self.assertTrue(isinstance(mocked_settings.MIGRATION_MODULES, DisableMigrations)) 357 | 358 | @patch('django_smoke_tests.generator.call_command') 359 | def test_if_settings_module_option_is_applied(self, mocked_call_command): 360 | settings_module = 'tests.settings' 361 | tests_generator = SmokeTestsGenerator(settings_module=settings_module) 362 | tests_generator.execute() 363 | mocked_call_command.assert_called_once_with( 364 | 'test', 'django_smoke_tests', settings=settings_module 365 | ) 366 | 367 | @patch('django_smoke_tests.generator.call_command') 368 | def test_if_configuration_option_is_applied(self, mocked_call_command): 369 | configuration = 'Development' 370 | tests_generator = SmokeTestsGenerator(configuration=configuration) 371 | tests_generator.execute() 372 | mocked_call_command.assert_called_once_with( 373 | 'test', 'django_smoke_tests', configuration=configuration 374 | ) 375 | 376 | def test_if_test_without_db_is_successful(self): 377 | tests_generator = SmokeTestsGenerator( 378 | use_db=False, 379 | allowed_status_codes=[404], # 404 expected with a random string url 380 | ) 381 | 382 | http_method = 'GET' 383 | endpoint_url = '/{}'.format(create_random_string()) 384 | expected_test_name = self.tests_generator.create_test_name( 385 | http_method, endpoint_url 386 | ) 387 | tests_generator.create_test_for_http_method( 388 | http_method, endpoint_url 389 | ) 390 | is_successful, failures, skipped = self._execute_smoke_test(expected_test_name) 391 | self.assertTrue(is_successful) 392 | self.assertEqual(failures, []) 393 | self.assertEqual(tests_generator.warnings, []) 394 | 395 | def test_no_db_test_runner(self): 396 | tests_generator = SmokeTestsGenerator( 397 | use_db=False, 398 | allowed_status_codes=[404], # 404 expected with a random string url 399 | ) 400 | http_method = 'GET' 401 | endpoint_url = '/{}'.format(create_random_string()) 402 | expected_test_name = self.tests_generator.create_test_name( 403 | http_method, endpoint_url 404 | ) 405 | tests_generator.create_test_for_http_method( 406 | http_method, endpoint_url 407 | ) 408 | suite = unittest.TestSuite() 409 | suite.addTest(SmokeTests(expected_test_name)) 410 | with captured_output() as (_, _): # skip output 411 | test_runner = NoDbTestRunner(stream=DummyStream, verbosity=-1).run_suite(suite) 412 | 413 | self.assertEqual(test_runner.errors, []) 414 | self.assertTrue(test_runner.wasSuccessful()) 415 | self.assertEqual(test_runner.failures, []) 416 | self.assertEqual(test_runner.skipped, []) 417 | 418 | @patch('django_smoke_tests.generator.call_command') 419 | def test_if_test_without_db_is_called_with_custom_runner(self, mocked_call_command): 420 | tests_generator = SmokeTestsGenerator(use_db=False) 421 | tests_generator.execute() 422 | mocked_call_command.assert_called_once_with( 423 | 'test', 'django_smoke_tests', testrunner='django_smoke_tests.runners.NoDbTestRunner' 424 | ) 425 | 426 | @patch('django_smoke_tests.generator.call_command') 427 | def test_smoke_test_is_created_only_for_specified_app( 428 | self, mocked_call_command 429 | ): 430 | outside_app_url_pattern = path( 431 | create_random_string(), 432 | RedirectView.as_view(url='/', permanent=False), 433 | name=create_random_string() 434 | ) 435 | outside_app_test_name = self.tests_generator.create_test_name( 436 | 'GET', get_pattern(outside_app_url_pattern) 437 | ) 438 | 439 | inside_app_url_pattern = app_url_patterns[0] 440 | inside_app_url_full_pattern = '^app_urls/' + get_pattern(inside_app_url_pattern) 441 | inside_app_test_name = self.tests_generator.create_test_name( 442 | 'GET', inside_app_url_full_pattern 443 | ) 444 | 445 | tests_generator = SmokeTestsGenerator(app_names=['tests.app']) 446 | tests_generator.execute() 447 | 448 | self.assertFalse( 449 | hasattr(SmokeTests, outside_app_test_name) 450 | ) 451 | 452 | self.assertTrue( 453 | hasattr(SmokeTests, inside_app_test_name) 454 | ) 455 | 456 | mocked_call_command.assert_called_once_with('test', 'django_smoke_tests') 457 | 458 | @patch('django_smoke_tests.generator.call_command') 459 | def test_if_error_is_raised_when_app_is_not_in_installed_apps(self, mocked_call_command): 460 | with self.assertRaises(AppNotInInstalledApps): 461 | tests_generator = SmokeTestsGenerator(app_names=[create_random_string()]) 462 | tests_generator.execute() 463 | mocked_call_command.assert_not_called() 464 | 465 | @patch('django_smoke_tests.generator.call_command') 466 | def test_if_view_decorated_with_wraps_is_added_for_specified_app(self, mocked_call_command): 467 | url_pattern = url_patterns_with_decorator_with_wraps[0] 468 | http_method = 'GET' 469 | tests_generator = SmokeTestsGenerator(app_names=['tests.app'], http_methods=[http_method]) 470 | tests_generator.execute() 471 | 472 | expected_test_name = self.tests_generator.create_test_name( 473 | http_method, '^app_urls/' + get_pattern(url_pattern) 474 | ) 475 | self.assertTrue( 476 | hasattr(SmokeTests, expected_test_name) 477 | ) 478 | mocked_call_command.assert_called_once() 479 | 480 | @patch('django_smoke_tests.generator.call_command') 481 | def test_if_view_decorated_without_wraps_is_not_added_for_specified_app( 482 | self, mocked_call_command 483 | ): 484 | # it's not possible to retrieve callback (view) module when it's wrapped in decorator 485 | url_pattern = url_patterns_with_decorator_without_wraps[0] 486 | http_method = 'GET' 487 | tests_generator = SmokeTestsGenerator(app_names=['tests.app'], http_methods=[http_method]) 488 | tests_generator.execute() 489 | 490 | expected_test_name = self.tests_generator.create_test_name( 491 | http_method, '^app_urls/' + get_pattern(url_pattern) 492 | ) 493 | self.assertFalse( 494 | hasattr(SmokeTests, expected_test_name) 495 | ) 496 | mocked_call_command.assert_called_once() 497 | 498 | @patch('django_smoke_tests.generator.call_command') 499 | def test_skipped_url(self, mocked_call_command): 500 | url_pattern = skipped_url_patterns[0] 501 | http_method = 'GET' 502 | tests_generator = SmokeTestsGenerator(http_methods=[http_method]) 503 | tests_generator.execute() 504 | 505 | expected_test_name = tests_generator.create_test_name( 506 | http_method, get_pattern(url_pattern) 507 | ) 508 | 509 | self.assertTrue( 510 | hasattr(SmokeTests, expected_test_name) 511 | ) 512 | 513 | is_successful, failures, skipped = self._execute_smoke_test(expected_test_name) 514 | 515 | self.assertTrue(is_successful) 516 | self.assertEqual(len(skipped), 1) 517 | self.assertEqual(failures, []) 518 | 519 | @patch('django_smoke_tests.generator.call_command') 520 | def test_skipped_app_url(self, mocked_call_command): 521 | url_pattern = skipped_app_url_patterns[0] 522 | http_method = 'GET' 523 | tests_generator = SmokeTestsGenerator(http_methods=[http_method]) 524 | tests_generator.execute() 525 | 526 | expected_test_name = tests_generator.create_test_name( 527 | http_method, '^app_urls/' + get_pattern(url_pattern) 528 | ) 529 | 530 | self.assertTrue( 531 | hasattr(SmokeTests, expected_test_name) 532 | ) 533 | 534 | is_successful, failures, skipped = self._execute_smoke_test(expected_test_name) 535 | 536 | self.assertTrue(is_successful) 537 | self.assertEqual(len(skipped), 1) 538 | self.assertEqual(failures, []) 539 | 540 | @patch('django_smoke_tests.generator.call_command') 541 | def test_skipped_app_url_using_namespace(self, mocked_call_command): 542 | url_pattern = another_app_skipped_urls[0] 543 | http_method = 'GET' 544 | tests_generator = SmokeTestsGenerator(http_methods=[http_method]) 545 | tests_generator.execute() 546 | 547 | expected_test_name = tests_generator.create_test_name( 548 | http_method, '^another_app_urls/' + get_pattern(url_pattern) 549 | ) 550 | 551 | self.assertTrue( 552 | hasattr(SmokeTests, expected_test_name) 553 | ) 554 | 555 | is_successful, failures, skipped = self._execute_smoke_test(expected_test_name) 556 | 557 | self.assertTrue(is_successful) 558 | self.assertEqual(len(skipped), 1) 559 | self.assertEqual(failures, []) 560 | 561 | @patch('django_smoke_tests.generator.call_command') 562 | def test_skipped_app_url_using_app_name(self, mocked_call_command): 563 | url_pattern = another_app_skipped_urls[1] 564 | http_method = 'GET' 565 | tests_generator = SmokeTestsGenerator(http_methods=[http_method]) 566 | tests_generator.execute() 567 | 568 | expected_test_name = tests_generator.create_test_name( 569 | http_method, '^another_app_urls/' + get_pattern(url_pattern) 570 | ) 571 | 572 | self.assertTrue( 573 | hasattr(SmokeTests, expected_test_name) 574 | ) 575 | 576 | is_successful, failures, skipped = self._execute_smoke_test(expected_test_name) 577 | 578 | self.assertTrue(is_successful) 579 | self.assertEqual(len(skipped), 1) 580 | self.assertEqual(failures, []) 581 | 582 | @override_settings(SKIP_SMOKE_TESTS=()) 583 | @patch('django_smoke_tests.generator.call_command') 584 | def test_if_url_is_not_skipped_when_setting_is_empty(self, mocked_call_command): 585 | url_pattern = skipped_url_patterns[0] 586 | http_method = 'GET' 587 | tests_generator = SmokeTestsGenerator(http_methods=[http_method]) 588 | tests_generator.execute() 589 | 590 | expected_test_name = tests_generator.create_test_name( 591 | http_method, get_pattern(url_pattern) 592 | ) 593 | 594 | self.assertTrue( 595 | hasattr(SmokeTests, expected_test_name) 596 | ) 597 | 598 | is_successful, failures, skipped = self._execute_smoke_test(expected_test_name) 599 | 600 | self.assertTrue(is_successful) 601 | self.assertEqual(skipped, []) 602 | self.assertEqual(failures, []) 603 | 604 | @patch('django_smoke_tests.tests.call_command') 605 | @patch('django_smoke_tests.generator.call_command') 606 | def test_if_fixture_is_applied(self, call_command_for_test, call_command_for_loaddata): 607 | fixture_path = 'file.json' 608 | tests_generator = SmokeTestsGenerator( 609 | fixture_path=fixture_path, 610 | allowed_status_codes=[404], # 404 expected with a random string url 611 | ) 612 | tests_generator.execute() 613 | 614 | http_method = 'GET' 615 | endpoint_url = '/{}'.format(create_random_string()) 616 | expected_test_name = self.tests_generator.create_test_name( 617 | http_method, endpoint_url 618 | ) 619 | tests_generator.create_test_for_http_method( 620 | http_method, endpoint_url 621 | ) 622 | is_successful, failures, skipped = self._execute_smoke_test(expected_test_name) 623 | 624 | self.assertTrue(is_successful) 625 | self.assertEqual(skipped, []) 626 | self.assertEqual(failures, []) 627 | 628 | call_command_for_test.assert_called_once_with( 629 | 'test', 'django_smoke_tests' 630 | ) 631 | call_command_for_loaddata.assert_called_once_with( 632 | 'loaddata', fixture_path 633 | ) 634 | --------------------------------------------------------------------------------