├── .coveragerc ├── .env.sample ├── .github └── workflows │ └── django.yml ├── .gitignore ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── School ├── __init__.py ├── asgi.py ├── github_settings.py ├── settings.py ├── urls.py └── wsgi.py ├── classroom ├── __init__.py ├── admin.py ├── api │ ├── __init__.py │ ├── serializers.py │ ├── tests │ │ └── test_views.py │ ├── urls.py │ └── views.py ├── apps.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_student_username.py │ ├── 0003_auto_20200224_1921.py │ └── __init__.py ├── models.py ├── tests.py └── views.py ├── manage.py ├── pyproject.toml ├── pytest Django and DRF.code-workspace ├── pytest.ini └── requirements.txt /.coveragerc: -------------------------------------------------------------------------------- 1 | # .coveragerc to control coverage.py 2 | [run] 3 | source = . 4 | omit = 5 | *venv/* 6 | *venv/bin/* 7 | *venv/include/* 8 | *venv/lib/* 9 | *manage.py 10 | */settings.py 11 | */local_settings.py 12 | */tests/* 13 | *apps.py 14 | *migrations/* 15 | *asgi.py 16 | *wsgi.py 17 | 18 | *__init__* 19 | */__pycache__/* 20 | */site-packages/* 21 | */distutils/* 22 | 23 | 24 | [report] 25 | #fail_under = 100 26 | show_missing = True 27 | #skip_covered = True 28 | -------------------------------------------------------------------------------- /.env.sample: -------------------------------------------------------------------------------- 1 | 2 | DATABASE_PASSWORD=postgres 3 | 4 | -------------------------------------------------------------------------------- /.github/workflows/django.yml: -------------------------------------------------------------------------------- 1 | name: Pytest CI 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | branches: [master] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | max-parallel: 4 14 | matrix: 15 | python-version: [3.6] 16 | 17 | services: 18 | postgres: 19 | image: postgres 20 | ports: 21 | - 5432:5432 22 | env: 23 | POSTGRES_DB: pytest_db 24 | POSTGRES_USER: postgres 25 | POSTGRES_PASSWORD: postgres 26 | 27 | options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 3 28 | 29 | steps: 30 | - uses: actions/checkout@v2 31 | - name: Set up Python ${{ matrix.python-version }} 32 | uses: actions/setup-python@v1 33 | with: 34 | python-version: ${{ matrix.python-version }} 35 | - name: Make a .env file 36 | run: | 37 | cp .env.sample .env 38 | - name: Install Dependencies 39 | run: | 40 | python -m pip install --upgrade pip 41 | pip install -r requirements.txt 42 | - name: Run Tests 43 | run: | 44 | pytest --ds=School.github_settings -v --nomigrations 45 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.pythonPath": "C:\\Users\\geoff\\AppData\\Local\\Programs\\Python\\Python37\\python.exe", 3 | "python.testing.pytestArgs": [ 4 | "." 5 | ], 6 | "python.testing.unittestEnabled": false, 7 | "python.testing.nosetestsEnabled": false, 8 | "python.testing.pytestEnabled": true, 9 | "python.testing.unittestArgs": [ 10 | "-v", 11 | "-s", 12 | ".", 13 | "-p", 14 | "*test.py" 15 | ] 16 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Geoffrey Nyaga 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Welcome to Pytest Django and DRF Tutorial 🦄 2 | 3 | ![](https://github.com/geoffreynyaga/django-and-djangorestframework-pytest-tutorial/workflows/Pytest%20CI/badge.svg) 4 | 5 | > This tutorial series is dedicated to exploring the basics of inbuilt Django testing and introduces 6 | > Pytest and its various plugins to help us write better code 7 | > 8 | > Currently we cover the following: 9 | > 10 | > - `Code coverage`, 11 | > - `Generating fixtures using Mixer` 12 | > - `Complex testing of edge-cases using Hypothesis` 13 | > - `Testing Serializers` 14 | > - `Testing DRF Views` 15 | > - `DRF authentication testing` 16 | > - `Github Actions` 17 | 18 | > ### Additional Resources 19 | > 20 | > - Full Course(FREE): https://geoffreynyaga.com/courses/pytest-django-and-django-rest-framework/ 21 | > - YouTube Playlist: https://www.youtube.com/playlist?list=PLP1DxoSC17LZTTzgfq0Dimkm6eWJQC9ki 22 | 23 | ## Installation 📥 24 | 25 | ```bash 26 | mkdir 27 | 28 | cd my_project 29 | 30 | git clone https://github.com/geoffreynyaga/django-and-djangorestframework-pytest-tutorial.git . 31 | ``` 32 | 33 | ## Testing setup🧪🧪 34 | 35 | The projects uses pytest and black as the formatting option. The tests also check for consistencies on code format. 36 | 37 | To initiate tests follow the steps below: 38 | 39 | 1. Its advised to create a virtual environment in the root folder 40 | 41 | ```bash 42 | virtualenv venv 43 | ``` 44 | 45 | 2. Activate the environent. 46 | 47 | a. For Linux/MacOS users use the command below 48 | 49 | ```bash 50 | source venv/bin/activate 51 | ``` 52 | 53 | b. for windows users 54 | 55 | ```bash 56 | cd venv/Scripts 57 | 58 | activate.bat 59 | ``` 60 | 61 | 3. Install the requirements 62 | 63 | ```bash 64 | pip install -r requirements.txt 65 | ``` 66 | 67 | 4. Run the pytest command 68 | 69 | ```bash 70 | pytest 71 | ``` 72 | 73 | The testing results will be displayed and there will also be a `htmlcov` folder generated inside the project that will contain the code coverage details. 74 | 75 |
 76 |     .
 77 |     ├── accounts
 78 |     │   ├── api
 79 |     │   │   └── tests
 80 |     │   └── migrations
 81 |     ├── classroom
 82 |     ├── htmlcov
 83 |     └── venv
 84 | 
85 | 86 | Open up the folder and open the `index.html` in your browser to see this information. 87 | 88 | ## Project layout 89 | 90 | ```bash 91 | . 92 | ├── accounts 93 | │ ├── api 94 | │ │ └── tests 95 | │ └── migrations 96 | ├── classroom 97 | ├── htmlcov 98 | └── venv # after you create the virtualenv 99 | ``` 100 | -------------------------------------------------------------------------------- /School/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geoffreynyaga/django-and-djangorestframework-pytest-tutorial/6d1f60ae642b83d475db8718ea3ee45d5177d5eb/School/__init__.py -------------------------------------------------------------------------------- /School/asgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | ASGI config for School project. 3 | 4 | It exposes the ASGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.0/howto/deployment/asgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.asgi import get_asgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "School.settings") 15 | 16 | application = get_asgi_application() 17 | -------------------------------------------------------------------------------- /School/github_settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for School project. 3 | 4 | Generated by 'django-admin startproject' using Django 3.0.2. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.0/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/3.0/ref/settings/ 11 | """ 12 | 13 | import os 14 | 15 | from decouple import config 16 | 17 | 18 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 19 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 20 | 21 | 22 | # Quick-start development settings - unsuitable for production 23 | # See https://docs.djangoproject.com/en/3.0/howto/deployment/checklist/ 24 | 25 | # SECURITY WARNING: keep the secret key used in production secret! 26 | SECRET_KEY = "4o0(y15#k-+6+kln0pc4-=)!8()hgbeb)4cjibblzj^7qe3hzv" 27 | 28 | # SECURITY WARNING: don't run with debug turned on in production! 29 | DEBUG = True 30 | 31 | ALLOWED_HOSTS = [] 32 | 33 | 34 | # Application definition 35 | 36 | INSTALLED_APPS = [ 37 | "django.contrib.admin", 38 | "django.contrib.auth", 39 | "django.contrib.contenttypes", 40 | "django.contrib.sessions", 41 | "django.contrib.messages", 42 | "django.contrib.staticfiles", 43 | "classroom", 44 | "rest_framework", 45 | "rest_framework.authtoken", 46 | ] 47 | 48 | MIDDLEWARE = [ 49 | "django.middleware.security.SecurityMiddleware", 50 | "django.contrib.sessions.middleware.SessionMiddleware", 51 | "django.middleware.common.CommonMiddleware", 52 | "django.middleware.csrf.CsrfViewMiddleware", 53 | "django.contrib.auth.middleware.AuthenticationMiddleware", 54 | "django.contrib.messages.middleware.MessageMiddleware", 55 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 56 | ] 57 | 58 | ROOT_URLCONF = "School.urls" 59 | 60 | TEMPLATES = [ 61 | { 62 | "BACKEND": "django.template.backends.django.DjangoTemplates", 63 | "DIRS": [], 64 | "APP_DIRS": True, 65 | "OPTIONS": { 66 | "context_processors": [ 67 | "django.template.context_processors.debug", 68 | "django.template.context_processors.request", 69 | "django.contrib.auth.context_processors.auth", 70 | "django.contrib.messages.context_processors.messages", 71 | ], 72 | }, 73 | }, 74 | ] 75 | 76 | WSGI_APPLICATION = "School.wsgi.application" 77 | 78 | 79 | # Database 80 | # https://docs.djangoproject.com/en/3.0/ref/settings/#databases 81 | 82 | # DATABASES = { 83 | # "default": { 84 | # "ENGINE": "django.db.backends.sqlite3", 85 | # "NAME": os.path.join(BASE_DIR, "db.sqlite3"), 86 | # } 87 | # } 88 | 89 | 90 | DATABASES = { 91 | "default": { 92 | "ENGINE": "django.db.backends.postgresql", 93 | "NAME": "pytest_db", 94 | "USER": "postgres", 95 | "PASSWORD": config("DATABASE_PASSWORD"), 96 | "HOST": "localhost", 97 | "PORT": "5432", 98 | } 99 | } 100 | 101 | 102 | # Password validation 103 | # https://docs.djangoproject.com/en/3.0/ref/settings/#auth-password-validators 104 | 105 | AUTH_PASSWORD_VALIDATORS = [ 106 | { 107 | "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", 108 | }, 109 | {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",}, 110 | {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",}, 111 | {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",}, 112 | ] 113 | 114 | 115 | # Internationalization 116 | # https://docs.djangoproject.com/en/3.0/topics/i18n/ 117 | 118 | LANGUAGE_CODE = "en-us" 119 | 120 | TIME_ZONE = "UTC" 121 | 122 | USE_I18N = True 123 | 124 | USE_L10N = True 125 | 126 | USE_TZ = True 127 | 128 | 129 | # Static files (CSS, JavaScript, Images) 130 | # https://docs.djangoproject.com/en/3.0/howto/static-files/ 131 | 132 | STATIC_URL = "/static/" 133 | 134 | 135 | REST_FRAMEWORK = { 136 | "DEFAULT_AUTHENTICATION_CLASSES": [ 137 | "rest_framework.authentication.BasicAuthentication", 138 | "rest_framework.authentication.SessionAuthentication", 139 | ] 140 | } 141 | -------------------------------------------------------------------------------- /School/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for School project. 3 | 4 | Generated by 'django-admin startproject' using Django 3.0.2. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.0/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/3.0/ref/settings/ 11 | """ 12 | 13 | import os 14 | 15 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 16 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 17 | 18 | 19 | # Quick-start development settings - unsuitable for production 20 | # See https://docs.djangoproject.com/en/3.0/howto/deployment/checklist/ 21 | 22 | # SECURITY WARNING: keep the secret key used in production secret! 23 | SECRET_KEY = "4o0(y15#k-+6+kln0pc4-=)!8()hgbeb)4cjibblzj^7qe3hzv" 24 | 25 | # SECURITY WARNING: don't run with debug turned on in production! 26 | DEBUG = True 27 | 28 | ALLOWED_HOSTS = [] 29 | 30 | 31 | # Application definition 32 | 33 | INSTALLED_APPS = [ 34 | "django.contrib.admin", 35 | "django.contrib.auth", 36 | "django.contrib.contenttypes", 37 | "django.contrib.sessions", 38 | "django.contrib.messages", 39 | "django.contrib.staticfiles", 40 | "classroom", 41 | "rest_framework", 42 | "rest_framework.authtoken", 43 | ] 44 | 45 | MIDDLEWARE = [ 46 | "django.middleware.security.SecurityMiddleware", 47 | "django.contrib.sessions.middleware.SessionMiddleware", 48 | "django.middleware.common.CommonMiddleware", 49 | "django.middleware.csrf.CsrfViewMiddleware", 50 | "django.contrib.auth.middleware.AuthenticationMiddleware", 51 | "django.contrib.messages.middleware.MessageMiddleware", 52 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 53 | ] 54 | 55 | ROOT_URLCONF = "School.urls" 56 | 57 | TEMPLATES = [ 58 | { 59 | "BACKEND": "django.template.backends.django.DjangoTemplates", 60 | "DIRS": [], 61 | "APP_DIRS": True, 62 | "OPTIONS": { 63 | "context_processors": [ 64 | "django.template.context_processors.debug", 65 | "django.template.context_processors.request", 66 | "django.contrib.auth.context_processors.auth", 67 | "django.contrib.messages.context_processors.messages", 68 | ], 69 | }, 70 | }, 71 | ] 72 | 73 | WSGI_APPLICATION = "School.wsgi.application" 74 | 75 | 76 | # Database 77 | # https://docs.djangoproject.com/en/3.0/ref/settings/#databases 78 | 79 | DATABASES = { 80 | "default": { 81 | "ENGINE": "django.db.backends.sqlite3", 82 | "NAME": os.path.join(BASE_DIR, "db.sqlite3"), 83 | } 84 | } 85 | 86 | # DATABASES = { 87 | # 'default': { 88 | # 'ENGINE': 'django.db.backends.postgresql', 89 | # 'NAME': 'mydatabase', 90 | # 'USER': 'mydatabaseuser', 91 | # 'PASSWORD': 'mypassword', 92 | # 'HOST': '127.0.0.1', 93 | # 'PORT': '5432', 94 | # } 95 | # } 96 | 97 | 98 | # Password validation 99 | # https://docs.djangoproject.com/en/3.0/ref/settings/#auth-password-validators 100 | 101 | AUTH_PASSWORD_VALIDATORS = [ 102 | { 103 | "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", 104 | }, 105 | {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",}, 106 | {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",}, 107 | {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",}, 108 | ] 109 | 110 | 111 | # Internationalization 112 | # https://docs.djangoproject.com/en/3.0/topics/i18n/ 113 | 114 | LANGUAGE_CODE = "en-us" 115 | 116 | TIME_ZONE = "UTC" 117 | 118 | USE_I18N = True 119 | 120 | USE_L10N = True 121 | 122 | USE_TZ = True 123 | 124 | 125 | # Static files (CSS, JavaScript, Images) 126 | # https://docs.djangoproject.com/en/3.0/howto/static-files/ 127 | 128 | STATIC_URL = "/static/" 129 | 130 | 131 | REST_FRAMEWORK = { 132 | "DEFAULT_PERMISSION_CLASSES": [ 133 | "rest_framework.permissions.IsAuthenticatedOrReadOnly" 134 | ], 135 | "DEFAULT_AUTHENTICATION_CLASSES": [ 136 | "rest_framework.authentication.BasicAuthentication", 137 | "rest_framework.authentication.TokenAuthentication", 138 | ], 139 | } 140 | -------------------------------------------------------------------------------- /School/urls.py: -------------------------------------------------------------------------------- 1 | """School URL Configuration 2 | 3 | The `urlpatterns` list routes URLs to views. For more information please see: 4 | https://docs.djangoproject.com/en/3.0/topics/http/urls/ 5 | Examples: 6 | Function views 7 | 1. Add an import: from my_app import views 8 | 2. Add a URL to urlpatterns: path('', views.home, name='home') 9 | Class-based views 10 | 1. Add an import: from other_app.views import Home 11 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') 12 | Including another URLconf 13 | 1. Import the include() function: from django.urls import include, path 14 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 15 | """ 16 | from django.contrib import admin 17 | from django.urls import path, include, re_path 18 | 19 | from rest_framework.authtoken import views 20 | 21 | 22 | urlpatterns = [ 23 | path("admin/", admin.site.urls), 24 | path("api/", include("classroom.api.urls")), 25 | re_path(r"^api-auth/", include("rest_framework.urls")), 26 | re_path(r"^api-token-auth/", views.obtain_auth_token), 27 | ] 28 | -------------------------------------------------------------------------------- /School/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for School 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/3.0/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "School.settings") 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /classroom/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geoffreynyaga/django-and-djangorestframework-pytest-tutorial/6d1f60ae642b83d475db8718ea3ee45d5177d5eb/classroom/__init__.py -------------------------------------------------------------------------------- /classroom/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from .models import Student, Classroom 3 | 4 | # Register your models here. 5 | admin.site.register(Student) 6 | admin.site.register(Classroom) 7 | -------------------------------------------------------------------------------- /classroom/api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geoffreynyaga/django-and-djangorestframework-pytest-tutorial/6d1f60ae642b83d475db8718ea3ee45d5177d5eb/classroom/api/__init__.py -------------------------------------------------------------------------------- /classroom/api/serializers.py: -------------------------------------------------------------------------------- 1 | from rest_framework.serializers import ModelSerializer 2 | 3 | from classroom.models import Student, Classroom 4 | 5 | 6 | class StudentSerializer(ModelSerializer): 7 | class Meta: 8 | model = Student 9 | fields = ( 10 | "first_name", 11 | "last_name", 12 | "username", 13 | "admission_number", 14 | "is_qualified", 15 | "average_score", 16 | ) 17 | 18 | 19 | class ClassroomSerializer(ModelSerializer): 20 | class Meta: 21 | model = Classroom 22 | fields = ( 23 | "name", 24 | "student_capacity", 25 | "students", 26 | ) 27 | -------------------------------------------------------------------------------- /classroom/api/tests/test_views.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from classroom.models import Student, Classroom 4 | 5 | from mixer.backend.django import mixer 6 | 7 | from django.test import TestCase 8 | from rest_framework.test import APIClient 9 | from rest_framework.reverse import reverse 10 | 11 | pytestmark = pytest.mark.django_db 12 | 13 | 14 | class TestStudentAPIViews(TestCase): 15 | def setUp(self): 16 | self.client = APIClient() 17 | 18 | print(self.client, "self.client") 19 | 20 | def test_student_list_works(self): 21 | # create a student 22 | 23 | student = mixer.blend(Student, first_name="Geoffrey") 24 | 25 | student2 = mixer.blend(Student, first_name="Naomi") 26 | 27 | url = reverse("student_list_api") 28 | 29 | # call the url 30 | response = self.client.get(url) 31 | 32 | # print(dir(response), "response") 33 | 34 | # aseertions 35 | # - json 36 | # - status 37 | assert response.json() != None 38 | 39 | assert len(response.json()) == 2 40 | 41 | assert response.status_code == 200 42 | 43 | def test_student_create_works(self): 44 | # data 45 | 46 | input_data = { 47 | "first_name": "Wangari", 48 | "last_name": "Maathai", 49 | "username": "", 50 | "admission_number": 9876, 51 | "is_qualified": True, 52 | "average_score": 100, 53 | } 54 | 55 | url = reverse("student_create_api") 56 | 57 | # call the url 58 | response = self.client.post(url, data=input_data) 59 | 60 | # assertions 61 | # - json 62 | # - status 63 | 64 | print(response.data) 65 | assert response.json() != None 66 | assert response.status_code == 201 67 | assert Student.objects.count() == 1 68 | 69 | def test_student_detail_works(self): 70 | # create a student 71 | 72 | student = mixer.blend(Student, pk=1, first_name="Geoffrey") 73 | print(Student.objects.last().pk, "qs") 74 | url = reverse("student_detail_api", kwargs={"pk": 1}) 75 | response = self.client.get(url) 76 | 77 | student2 = mixer.blend(Student, pk=2, first_name="Naomi") 78 | url2 = reverse("student_detail_api", kwargs={"pk": 2}) 79 | response2 = self.client.get(url2) 80 | 81 | # assertions 82 | # - json 83 | # - status 84 | 85 | print(response.json(), "response json") 86 | 87 | assert response.json() != None 88 | assert response.status_code == 200 89 | assert response.json()["first_name"] == "Geoffrey" 90 | assert response.json()["username"] == "geoffrey" 91 | 92 | assert response2.json()["first_name"] == "Naomi" 93 | assert response2.json()["username"] == "naomi" 94 | 95 | def test_student_delete_works(self): 96 | # create a student 97 | 98 | student = mixer.blend(Student, pk=1, first_name="Geoffrey") 99 | assert Student.objects.count() == 1 100 | 101 | url = reverse("student_delete_api", kwargs={"pk": 1}) 102 | response = self.client.delete(url) 103 | # assertions 104 | # - json 105 | # - status 106 | 107 | print(dir(response.json), "response json") 108 | print((response.status_code), "response json") 109 | 110 | assert response.status_code == 204 111 | 112 | assert Student.objects.count() == 0 113 | 114 | 115 | class TestClassroomAPIViews(TestCase): 116 | def setUp(self): 117 | self.client = APIClient() 118 | 119 | print(self.client, "self.client") 120 | 121 | # method 1 122 | 123 | # from rest_framework.authtoken.models import Token 124 | 125 | # from django.contrib.auth import get_user_model 126 | 127 | # User = get_user_model() 128 | 129 | # self.our_user = User.objects.create(username="testuser", password="abcde") 130 | 131 | # self.token = Token.objects.create(user=self.our_user) 132 | 133 | # print(self.token.key, "token") 134 | 135 | # self.client.credentials(HTTP_AUTHORIZATION="Token " + self.token.key) 136 | 137 | # method 2 # normal 138 | 139 | from django.contrib.auth import get_user_model 140 | 141 | User = get_user_model() 142 | 143 | self.our_user = User.objects.create_user(username="testuser", password="abcde") 144 | 145 | self.token_url = "http://localhost:8000/api-token-auth/" 146 | 147 | user_data = {"username": "testuser", "password": "abcde"} 148 | 149 | response = self.client.post(self.token_url, data=user_data) 150 | 151 | # print(dir(response.), "reponse") 152 | print((response.data), "reponse") 153 | """ 154 | { 155 | "token": "b89d0bab1b4f818c5af6682cec66f84b0bdb664c" 156 | } 157 | """ 158 | 159 | self.client.credentials(HTTP_AUTHORIZATION="Token " + response.data["token"]) 160 | 161 | def test_classroom_qs_works(self): 162 | classroom = mixer.blend(Classroom, student_capacity=20) 163 | classroom2 = mixer.blend(Classroom, student_capacity=27) 164 | 165 | url = reverse("class_qs_api", kwargs={"student_capacity": 15}) 166 | 167 | response = self.client.get(url,) 168 | 169 | assert response.status_code == 202 170 | assert response.data["classroom_data"] != [] 171 | assert response.data["number_of_classes"] == 2 172 | -------------------------------------------------------------------------------- /classroom/api/urls.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.urls import path 3 | 4 | from .views import ( 5 | StudentListAPIView, 6 | StudentCreateAPIView, 7 | StudentDeleteAPIView, 8 | StudentDetailAPIView, 9 | ClassroomNumberAPIView, 10 | ) 11 | 12 | urlpatterns = [ 13 | path("student/list/", StudentListAPIView.as_view(), name="student_list_api"), 14 | path("student/create/", StudentCreateAPIView.as_view(), name="student_create_api"), 15 | path( 16 | "student//", StudentDetailAPIView.as_view(), name="student_detail_api" 17 | ), 18 | path( 19 | "student//delete/", 20 | StudentDeleteAPIView.as_view(), 21 | name="student_delete_api", 22 | ), 23 | path( 24 | "classroom//", 25 | ClassroomNumberAPIView.as_view(), 26 | name="class_qs_api", 27 | ), 28 | ] 29 | -------------------------------------------------------------------------------- /classroom/api/views.py: -------------------------------------------------------------------------------- 1 | from rest_framework.generics import ( 2 | ListAPIView, 3 | CreateAPIView, 4 | DestroyAPIView, 5 | RetrieveAPIView, 6 | ) 7 | 8 | from rest_framework import status, permissions, authentication 9 | 10 | from rest_framework.views import APIView 11 | 12 | from rest_framework.response import Response 13 | 14 | from .serializers import StudentSerializer, ClassroomSerializer 15 | from classroom.models import Student, Classroom 16 | 17 | 18 | class StudentListAPIView(ListAPIView): 19 | serializer_class = StudentSerializer 20 | model = Student 21 | queryset = Student.objects.all() 22 | 23 | 24 | class StudentCreateAPIView(CreateAPIView): 25 | serializer_class = StudentSerializer 26 | model = Student 27 | queryset = Student.objects.all() 28 | 29 | 30 | class StudentDetailAPIView(RetrieveAPIView): 31 | serializer_class = StudentSerializer 32 | model = Student 33 | queryset = Student.objects.all() 34 | 35 | 36 | class StudentDeleteAPIView(DestroyAPIView): 37 | serializer_class = StudentSerializer 38 | model = Student 39 | queryset = Student.objects.all() 40 | 41 | 42 | class ClassroomNumberAPIView(APIView): 43 | 44 | serializer_class = ClassroomSerializer 45 | model = Classroom 46 | queryset = Student.objects.all() 47 | 48 | permission_classes = [permissions.IsAuthenticated] 49 | authentication_classes = [authentication.TokenAuthentication] 50 | 51 | def get(self, *args, **kwargs): 52 | 53 | url_number = self.kwargs.get("student_capacity") 54 | print(url_number, "student_capacity") 55 | 56 | classroom_qs = Classroom.objects.filter(student_capacity__gte=url_number) 57 | print(classroom_qs, "classroom_qs") 58 | 59 | number_of_classes = classroom_qs.count() 60 | 61 | serialized_data = ClassroomSerializer(classroom_qs, many=True) 62 | # print(serialized_data.data, "serialized_data") 63 | 64 | if serialized_data.is_valid: 65 | return Response( 66 | { 67 | "classroom_data": serialized_data.data, 68 | "number_of_classes": number_of_classes, 69 | }, 70 | status=status.HTTP_202_ACCEPTED, 71 | ) 72 | else: 73 | return Response( 74 | {"Error": "Could not serialize data"}, 75 | status=status.HTTP_203_NON_AUTHORITATIVE_INFORMATION, 76 | ) 77 | -------------------------------------------------------------------------------- /classroom/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class ClassroomConfig(AppConfig): 5 | name = "classroom" 6 | -------------------------------------------------------------------------------- /classroom/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.2 on 2020-02-01 21:40 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | initial = True 9 | 10 | dependencies = [] 11 | 12 | operations = [ 13 | migrations.CreateModel( 14 | name="Student", 15 | fields=[ 16 | ( 17 | "id", 18 | models.AutoField( 19 | auto_created=True, 20 | primary_key=True, 21 | serialize=False, 22 | verbose_name="ID", 23 | ), 24 | ), 25 | ("first_name", models.CharField(max_length=50)), 26 | ("last_name", models.CharField(max_length=50)), 27 | ("admission_number", models.IntegerField(unique=True)), 28 | ("is_qualified", models.BooleanField(default=False)), 29 | ("average_score", models.FloatField(blank=True, null=True)), 30 | ], 31 | ), 32 | migrations.CreateModel( 33 | name="Classroom", 34 | fields=[ 35 | ( 36 | "id", 37 | models.AutoField( 38 | auto_created=True, 39 | primary_key=True, 40 | serialize=False, 41 | verbose_name="ID", 42 | ), 43 | ), 44 | ("name", models.CharField(max_length=120)), 45 | ("student_capacity", models.IntegerField()), 46 | ("students", models.ManyToManyField(to="classroom.Student")), 47 | ], 48 | ), 49 | ] 50 | -------------------------------------------------------------------------------- /classroom/migrations/0002_student_username.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.2 on 2020-02-20 11:28 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("classroom", "0001_initial"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="student", 15 | name="username", 16 | field=models.SlugField(blank=True, null=True), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /classroom/migrations/0003_auto_20200224_1921.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.2 on 2020-02-24 19:21 2 | 3 | import classroom.models 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('classroom', '0002_student_username'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name='student', 16 | name='average_score', 17 | field=models.FloatField(blank=True, null=True, validators=[classroom.models.validate_negative]), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /classroom/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geoffreynyaga/django-and-djangorestframework-pytest-tutorial/6d1f60ae642b83d475db8718ea3ee45d5177d5eb/classroom/migrations/__init__.py -------------------------------------------------------------------------------- /classroom/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.utils.text import slugify 3 | 4 | from django.core.exceptions import ValidationError 5 | from django.utils.translation import gettext_lazy as _ 6 | 7 | 8 | def validate_negative(value): 9 | if value < 0: 10 | raise ValidationError( 11 | _("%(value)s is not an posituve number"), params={"value": value}, 12 | ) 13 | 14 | 15 | class Student(models.Model): 16 | """Model definition for Student.""" 17 | 18 | first_name = models.CharField(max_length=50) 19 | last_name = models.CharField(max_length=50) 20 | 21 | username = models.SlugField(blank=True, null=True) 22 | 23 | admission_number = models.IntegerField(unique=True) 24 | 25 | is_qualified = models.BooleanField(default=False) 26 | 27 | average_score = models.FloatField( 28 | blank=True, null=True, validators=[validate_negative] 29 | ) 30 | 31 | def __str__(self): 32 | """Unicode representation of Student.""" 33 | return self.first_name 34 | 35 | def get_grade(self): 36 | if 0 <= self.average_score < 40: 37 | return "Fail" 38 | elif 40 <= self.average_score < 70: 39 | return "Pass" 40 | elif 70 <= self.average_score < 100: 41 | return "Excellent" 42 | else: 43 | return "Error" 44 | 45 | def save(self, *args, **kwargs): 46 | self.username = slugify(self.first_name) 47 | super(Student, self).save(*args, **kwargs) 48 | 49 | 50 | class Classroom(models.Model): 51 | name = models.CharField(max_length=120) 52 | student_capacity = models.IntegerField() 53 | students = models.ManyToManyField("classroom.Student") 54 | 55 | def __str__(self): 56 | return self.name 57 | -------------------------------------------------------------------------------- /classroom/tests.py: -------------------------------------------------------------------------------- 1 | # from django.test import TestCase 2 | from hypothesis.extra.django import TestCase 3 | import pytest 4 | from hypothesis import strategies as st, given 5 | from classroom.models import Student, Classroom 6 | 7 | from mixer.backend.django import mixer 8 | 9 | pytestmark = pytest.mark.django_db 10 | 11 | 12 | class TestStudentModel(TestCase): 13 | # def setUp(self): 14 | 15 | # self.student1 = Student.objects.create( 16 | # first_name="Tom", last_name="Mboya", admission_number=12345 17 | # ) 18 | 19 | # setting up new users 20 | # getting access tokens / logged in users 21 | # setup up timers 22 | 23 | def test_add_a_plus_b(self): 24 | a = 1 25 | b = 2 26 | c = a + b 27 | 28 | assert c == 3 29 | 30 | def test_student_can_be_created(self): 31 | 32 | student1 = mixer.blend(Student, first_name="Tom") 33 | 34 | student_result = Student.objects.last() # getting the last student 35 | 36 | assert student_result.first_name == "Tom" 37 | 38 | def test_str_return(self): 39 | 40 | student1 = mixer.blend(Student, first_name="Tom") 41 | 42 | student_result = Student.objects.last() # getting the last student 43 | 44 | assert str(student_result) == "Tom" 45 | 46 | # @given(st.characters()) 47 | # def test_slugify(self, name): 48 | 49 | # print(name, "name") 50 | 51 | # student1 = mixer.blend(Student, first_name=name) 52 | # student1.save() 53 | 54 | # student_result = Student.objects.last() # getting the last student 55 | 56 | # assert len(str(student_result.username)) == len(name) 57 | 58 | @given(st.floats(min_value=0, max_value=40)) 59 | def test_grade_fail(self, fail_score): 60 | 61 | print(fail_score, "this is failscore") 62 | 63 | student1 = mixer.blend(Student, average_score=fail_score) 64 | 65 | student_result = Student.objects.last() # getting the last student 66 | 67 | assert student_result.get_grade() == "Fail" 68 | 69 | @given(st.floats(min_value=40, max_value=70)) 70 | def test_grade_pass(self, pass_grade): 71 | 72 | student1 = mixer.blend(Student, average_score=pass_grade) 73 | 74 | student_result = Student.objects.last() # getting the last student 75 | 76 | assert student_result.get_grade() == "Pass" 77 | 78 | @given(st.floats(min_value=70, max_value=100)) 79 | def test_grade_excellent(self, excellent_grade): 80 | 81 | student1 = mixer.blend(Student, average_score=excellent_grade) 82 | 83 | student_result = Student.objects.last() # getting the last student 84 | 85 | assert student_result.get_grade() == "Excellent" 86 | 87 | @given(st.floats(min_value=100)) 88 | def test_grade_error(self, error_grade): 89 | 90 | student1 = mixer.blend(Student, average_score=error_grade) 91 | 92 | student_result = Student.objects.last() # getting the last student 93 | 94 | assert student_result.get_grade() == "Error" 95 | 96 | 97 | class TestClassroomModel: 98 | def test_classroom_create(self): 99 | classroom = mixer.blend(Classroom, name="Physics") 100 | 101 | classroom_result = Classroom.objects.last() # getting the last student 102 | 103 | assert str(classroom_result) == "Physics" 104 | -------------------------------------------------------------------------------- /classroom/views.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import render 2 | 3 | # Create your views here. 4 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "School.settings") 9 | try: 10 | from django.core.management import execute_from_command_line 11 | except ImportError as exc: 12 | raise ImportError( 13 | "Couldn't import Django. Are you sure it's installed and " 14 | "available on your PYTHONPATH environment variable? Did you " 15 | "forget to activate a virtual environment?" 16 | ) from exc 17 | execute_from_command_line(sys.argv) 18 | 19 | 20 | if __name__ == "__main__": 21 | main() 22 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | # Example configuration for Black. 2 | 3 | # NOTE: you have to use single-quoted strings in TOML for regular expressions. 4 | # It's the equivalent of r-strings in Python. Multiline strings are treated as 5 | # verbose regular expressions by Black. Use [ ] to denote a significant space 6 | # character. 7 | 8 | [tool.black] 9 | line-length = 88 10 | target-version = ['py36', 'py37', 'py38'] 11 | include = '\.pyi?$' 12 | exclude = '(/(\.venv|migrations)|.*\/_settings\.py.*|tests/)' 13 | -------------------------------------------------------------------------------- /pytest Django and DRF.code-workspace: -------------------------------------------------------------------------------- 1 | { 2 | "folders": [ 3 | { 4 | "path": "." 5 | } 6 | ], 7 | "settings": {} 8 | } 9 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | DJANGO_SETTINGS_MODULE = School.local_settings 3 | 4 | #optional but recommended 5 | python_files = tests.py test_*.py *_test.py 6 | 7 | addopts = -v --nomigrations --ignore=venv --cov=. --cov-report=html 8 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | apipkg==1.5 2 | appdirs==1.4.3 3 | asgiref==3.2.3 4 | attrs==19.3.0 5 | black==19.10b0 6 | Click==7.0 7 | coverage==5.0.3 8 | Django==3.0.3 9 | djangorestframework==3.11.0 10 | execnet==1.7.1 11 | Faker==0.9.1 12 | hypothesis==5.5.4 13 | importlib-metadata==1.5.0 14 | Markdown==3.2.1 15 | mixer==6.1.3 16 | more-itertools==8.2.0 17 | mypy==0.761 18 | mypy-extensions==0.4.3 19 | packaging==20.1 20 | pathspec==0.7.0 21 | pluggy==0.13.1 22 | psycopg2-binary==2.8.4 23 | py==1.8.1 24 | pyparsing==2.4.6 25 | pytest==5.3.5 26 | pytest-black==0.3.8 27 | pytest-cov==2.8.1 28 | pytest-django==3.8.0 29 | pytest-forked==1.1.3 30 | pytest-sugar==0.9.2 31 | pytest-xdist==1.31.0 32 | python-dateutil==2.8.1 33 | python-decouple==3.3 34 | pytz==2019.3 35 | regex==2020.1.8 36 | six==1.14.0 37 | sortedcontainers==2.1.0 38 | sqlparse==0.3.0 39 | termcolor==1.1.0 40 | text-unidecode==1.2 41 | toml==0.10.0 42 | typed-ast==1.4.1 43 | typing-extensions==3.7.4.1 44 | wcwidth==0.1.8 45 | zipp==2.2.0 46 | --------------------------------------------------------------------------------