diff --git a/.circleci/config.yml b/.circleci/config.yml
index dbbe20cdf7c83ee6f5ba25ff1459382e42cce3b1..a27640ce7f49a47b665d3be15e2558ddcdc44d90 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -96,7 +96,7 @@ jobs:
           command: |
             python3 -m venv venv
             . venv/bin/activate
-            pip install coverage codecov
+            pip install codecov pytest-circleci-parallelized
             pip install -r requirements-dev.txt
       - save_cache:
           paths:
@@ -118,12 +118,12 @@ jobs:
           name: run linting
           command: |
             . venv/bin/activate
-            flake8 ./hypha
-            make sort
+            make lint
 
   test-be:
     executor: with-database
     working_directory: ~/repo
+    parallelism: 4
     steps:
       - checkout
       - attach_workspace:
@@ -135,7 +135,7 @@ jobs:
             python manage.py check
             python manage.py makemigrations --check --noinput --verbosity=1
             python manage.py collectstatic --noinput --no-post-process --verbosity=1
-            coverage run --source='hypha' manage.py test
+            pytest --cov --cov-report term:skip-covered -n 1 --circleci-parallelize
             codecov
 
 
diff --git a/.gitignore b/.gitignore
index 286108bda28b493d9eed306bb4cf198bbdaa12ac..1b26e06cb6c7f398de37de21cfa31f7f7078e041 100644
--- a/.gitignore
+++ b/.gitignore
@@ -32,10 +32,12 @@ wheels/
 *.egg-info/
 .installed.cfg
 *.egg
-
+.coverage
+htmlcov/
 
 # Cache
 .mypy_cache/
+.pytest_cache/
 
 # Webpack
 webpack-stats.json
diff --git a/.nvmrc b/.nvmrc
new file mode 100644
index 0000000000000000000000000000000000000000..6276cf12fb16ba037ed782e59ce3e960a1893708
--- /dev/null
+++ b/.nvmrc
@@ -0,0 +1 @@
+v16.14.2
diff --git a/Makefile b/Makefile
index 7bd7a3c791bff0040b3e798f1c5093fe8ce6dfa0..39de3e839512c8e2884f4c03cfe2c4624220572d 100644
--- a/Makefile
+++ b/Makefile
@@ -3,6 +3,9 @@ help:
 	@echo "Usage:"
 	@echo "    make help             prints this help."
 	@echo "    make lint             run all python linting."
+	@echo "    make test             run linting and test and generate html coverage report"
+	@echo "    make py-test          run all python tests and display coverage"
+	@echo "    make cov-html         generate html coverage report"
 	@echo "    make sort             run the isort import linter."
 	@echo "    make sort-fix         fix import sort order."
 	@echo "    make style            run the python code style linter."
@@ -12,12 +15,30 @@ lint: sort style
 
 .PHONY: sort
 sort:
-	@echo "Checking imports with isort" && isort --check-only --diff .
+	@echo "Checking imports with isort" && isort --check-only --diff hypha
 
 .PHONY: sort-fix
 sort-fix:
-	@echo "Fixing imports with isort" && isort .
+	@echo "Fixing imports with isort" && isort hypha
 
 .PHONY: style
 style:
 	@echo "Checking code style with flake8" && flake8 .
+
+.PHONY: cov-htmlcov
+cov-html:
+ifneq ("$(wildcard .coverage)","")
+	@rm -rf htmlcov
+	@echo "Generate html coverage report..." && coverage html
+	@echo "Open 'htmlcov/index.html' in your browser to see the report."
+else
+	$(error Unable to generate html coverage report, please run 'make test' or 'make py-test')
+endif
+
+.PHONY: py-test
+py-test:
+	@echo "Running python tests"
+	pytest --reuse-db --cov --cov-report term:skip-covered
+
+.PHONY: test
+test: lint py-test cov-html
diff --git a/hypha/settings/test.py b/hypha/settings/test.py
index 7e3a944ccf7ca37e377d66ce1cacd8aab4b6f6fd..9857fd223312b564eb9710e256c51ae7fee61ed9 100644
--- a/hypha/settings/test.py
+++ b/hypha/settings/test.py
@@ -24,3 +24,6 @@ PASSWORD_HASHERS = [
 ]
 
 WAGTAILADMIN_BASE_URL = "https://primary-test-host.org"
+
+# Required by django-coverage-plugin to report template coverage
+TEMPLATES[0]['OPTIONS']['debug'] = True
diff --git a/requirements-dev.txt b/requirements-dev.txt
index beb4b714732af9e47ee814c6ec9b50edd924bf43..77a34d288b3fc3ba927c5581d36852154bb233c7 100644
--- a/requirements-dev.txt
+++ b/requirements-dev.txt
@@ -1,11 +1,16 @@
 -r requirements.txt
 
-django-debug-toolbar==3.2.2
+django-debug-toolbar==3.6.0
 factory_boy==3.2.1
-flake8==5.0.1
+flake8==5.0.4
 isort==5.10.1
-model-bakery==1.3.3
-responses==0.16.0
+model-bakery==1.7.0
+responses==0.21.0
 stellar==0.4.5
 wagtail-factories==2.1.0
-Werkzeug==2.0.2
+Werkzeug==2.2.2
+pytest-django==4.5.2
+pytest-xdist[psutil]==2.5.0
+coverage==6.4.4
+pytest-cov==3.0.0
+django-coverage-plugin==2.0.3
diff --git a/setup.cfg b/setup.cfg
index 42f31d708343fee0e623d153adada99bc8b3d864..a0f71306e84a8f0e02d55f4d9c215e0b1197fed9 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -1,7 +1,15 @@
 [flake8]
 ignore = E501,W503,F405,F821,W504,W605
-exclude = migrations,node_modules,venv
 max-line-length = 88
+exclude =
+    .*/,
+    __pycache__/,
+    *migrations/,
+    node_modules/,
+    venv/,
+    media/,
+    static/,
+    htmlcov/
 
 [isort]
 force_grid_wrap = 0
@@ -10,3 +18,19 @@ line_length = 88
 multi_line_output = 3
 skip_glob = .direnv,node_modules,venv,**/migrations/**
 use_parentheses = True
+
+[tool:pytest]
+DJANGO_SETTINGS_MODULE = hypha.settings.test
+addopts = -n auto --failed-first
+python_files = tests.py test_*.py *_tests.py
+testpaths =
+    hypha
+filterwarnings =
+    ignore::DeprecationWarning
+    ignore::PendingDeprecationWarning
+
+[coverage:run]
+plugins = django_coverage_plugin
+omit =
+    *migrations*,
+    *test*