diff --git a/.gitignore b/.gitignore
index dda2d591cb71163c7f9a54d53a6e7dbd81dee413..9d407fd51a95894af56c482f15ec96d2e8e6d67e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,7 @@
 __pycache__
 .coverage
 coverage.xml
+pytest.xml
 htmlcov
 .tox
 *.egg-info
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 1f2565d055b8dec4442282e84cf540715ce6f2b5..673d36860714329fcce5fe0f8a89cd1302b509d3 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -4,12 +4,13 @@ stages:
 
 test:
   stage: test
-  image: registry.git.autistici.org/ai3/docker/test/python:master
+  image: registry.git.autistici.org/pipelines/images/test/python:master
   script:
     - tox
+  coverage: '/TOTAL.*\s+(\d+%)$/'
   artifacts:
     reports:
       coverage_report:
         coverage_format: cobertura
         path: cover.xml
-      junit: nosetests.xml
+      junit: pytest.xml
diff --git a/ai_web_common/flask_ai/test/test_simple_app.py b/ai_web_common/flask_ai/test/test_simple_app.py
index c984e76e5d2b50d768bbc835c769bda31c5f82ea..127868616c0998a4e078cd866e7d40f8c664b124 100644
--- a/ai_web_common/flask_ai/test/test_simple_app.py
+++ b/ai_web_common/flask_ai/test/test_simple_app.py
@@ -18,12 +18,12 @@ class TestSimpleApp(TestCase):
     def create_app(self):
         app.config.update({
             'TESTING': True,
-            'DEBUG': False, 
+            'DEBUG': False,
         })
         init_app(app, talisman)
         return app
 
     def test_request(self):
         r = self.client.get('/')
-        self.assertEquals(200, r.status_code)
-        self.assertEquals('ok', r.data.decode('utf-8'))
+        self.assertEqual(200, r.status_code)
+        self.assertEqual('ok', r.data.decode('utf-8'))
diff --git a/ai_web_common/rpc/core.py b/ai_web_common/rpc/core.py
index 7cc53ab4b4f0f9618cfdb4459f75eef3a212a0e1..7fe50176f2179b34abb8ecb5f169c4b0e22beacd 100644
--- a/ai_web_common/rpc/core.py
+++ b/ai_web_common/rpc/core.py
@@ -139,7 +139,7 @@ def _json_request_encoder(req):
 
 
 def _json_response_decoder(resp):
-    content_type = resp.getheader('Content-Type')
+    content_type = resp.headers.get('Content-Type')
     if not content_type or not content_type.startswith('application/json'):
         raise ValueError('response is not application/json')
 
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000000000000000000000000000000000000..49639e46ca941e215fa3b3567b0d612d69a70a94
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,10 @@
+backoff
+cryptography
+Flask
+flask-talisman
+opentelemetry-distro
+opentelemetry-exporter-zipkin-json
+opentelemetry-instrumentation-flask
+urllib3
+whitenoise
+git+https://git.autistici.org/ai/sso.git#egg=sso&subdirectory=src/python
diff --git a/tox.ini b/tox.ini
index d01873c4a5acca69d329843160606112d7777288..ed0d33feeb59753563359e8bf99254fc6e430dad 100644
--- a/tox.ini
+++ b/tox.ini
@@ -3,13 +3,14 @@ envlist = py3
 
 [testenv]
 deps=
-  git+https://git.autistici.org/ai/sso.git#egg=sso&subdirectory=src/python
-  cryptography
+  -rrequirements.txt
   Flask-Testing
   coverage
-  nose
   mock
+  pytest
+  pytest-cov
 commands=
-  nosetests -vv --with-xunit --with-coverage --cover-package=ai_web_common --cover-erase --cover-html --cover-html-dir=htmlcov []
+  pytest --junitxml=pytest.xml --cov=ai_web_common --cov-report html:htmlcov
+  coverage report
   coverage xml