diff --git a/MANIFEST.in b/MANIFEST.in
index d79ea0268d481ed3370c7379c7ef90add6c227be..b13ed9c7090e38c21c3dfea23425f74b879f3da2 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -3,5 +3,4 @@ include README.md
 include VERSION
 
 recursive-include docs *
-
 recursive-exclude tests *
diff --git a/tests/requirements.txt b/tests/requirements.txt
new file mode 100644
index 0000000000000000000000000000000000000000..4a545b21000d12a9bd8277f13835031616228821
--- /dev/null
+++ b/tests/requirements.txt
@@ -0,0 +1,4 @@
+black
+build
+flake8
+pylint
diff --git a/tox.ini b/tox.ini
new file mode 100644
index 0000000000000000000000000000000000000000..31c19d913ae913bc8ef51b32908b8bd51c8bb8c6
--- /dev/null
+++ b/tox.ini
@@ -0,0 +1,75 @@
+[tox]
+# Generative environment list to test all supported Python versions
+envlist = py3{7,8,9,10},black,pep8,pylint
+minversion = 3.18.0
+# Source distributions are explicitly build using tox -e build
+skipsdist = True
+
+[testenv]
+usedevelop = True
+setenv =
+    LANGUAGE=en_US
+    LC_ALL=en_US.UTF-8
+    PYTHONWARNINGS=default::DeprecationWarning
+deps =
+    -r{toxinidir}/requirements.txt
+    -r{toxinidir}/tests/requirements.txt
+commands =
+    {envpython} --version
+    stestr run {posargs}
+
+# Use generative name and command prefixes to reuse the same virtualenv
+# for all linting jobs.
+[testenv:{pep8,black,pylint}]
+usedevelop = False
+envdir = {toxworkdir}/linting
+commands =
+    pep8: {envpython} -m flake8 --version
+    pep8: {envpython} -m flake8
+    black: {envpython} -m black --version
+    black: {envpython} -m black --check --diff .
+    pylint: {envpython} -m pylint --version
+    pylint: {envpython} -m pylint lofar_station_client
+
+[testenv:debug]
+commands = {envpython} -m testtools.run {posargs}
+
+[testenv:coverage]
+; stestr does not natively support generating coverage reports use
+; `PYTHON=python -m coverage run....` to overcome this.
+setenv =
+    PYTHON={envpython} -m coverage run --source lofar_station_client --omit='*tests*' --parallel-mode
+commands =
+  {envpython} -m coverage erase
+  {envpython} -m stestr run {posargs}
+  {envpython} -m coverage combine
+  {envpython} -m coverage html -d cover --omit='*tests*'
+  {envpython} -m coverage xml -o coverage.xml
+  {envpython} -m coverage report --omit='*tests*'
+
+[testenv:build]
+usedevelop = False
+commands = {envpython} -m build
+
+[testenv:docs]
+deps =
+  -r{toxinidir}/requirements.txt
+  -r{toxinidir}/docs/requirements.txt
+commands = sphinx-build -b html -W docs/source docs/build/html
+
+[testenv:integration]
+# Do no install the lofar station client package, force packaged version install
+skip_install = true
+# Intentionally break import paths if not installed from package
+changedir={toxinidir}/integration
+# Allow bash for wheel file substitution
+allowlist_external =
+    bash
+commands =
+    # We need the bash substitutions here
+    bash -ec 'pip install --force-reinstall {toxinidir}/dist/*.whl'
+    {envpython} -m stestr run --serial {posargs}
+
+[flake8]
+filename = *.py
+exclude=.tox,.egg-info