Repository: spiral-project/ihatemoney (language: python)

Issue:
Migrate to pytest
Fix #1148

Well for now, this is just experiment, but showing good signs of success.
Migrate to pytest
Currently, we are using pytest for **launching** tests, but they are all written with [Flask-Testing](https://github.com/jarus/flask-testing) and unittest.

Since Flask-Testing is nearly dead, and Flask is moving fastly, we should move away from it, and use another way to write test. As far as I can tell, the [base documentation for testing Flask](https://flask.palletsprojects.com/en/2.2.x/testing/) can easily use fixtures of pytest.

In this issue, I want to discuss what we are currently using, and what could be done to move to pytest.

However, there might be other library I'm not aware of.


Discussion / hints:
Here it is!
Flask-Testing is now removed, we will be able to upgrade everything else! And write more `pytest`-esque tests.
Thanks for working on this.

What's the rationale for moving to pytest-style tests? Honestly, I find pytest fixtures a bit confusing.

From what I understand, pytest understands unitttest-style tests, so we can just keep using that?

Btw, does pytest have native support for MagicMock and patch?
Ah, we need an explanation/discussion/debate. Good!
**Disclaimer**: I like the pytest pythonic approach of writing tests. This is purely subjective, so I think it's good to say it before reading.

**fixtures are reusable `setUp()`/`teardown()`**. You just write some code in a function (the `setUp`), then `yield` (the value, or nothing), then some code (the `teardown`). And you can write small functions, they all contains, in one go, their `setUp/teardown` phases. This is a huge win for me.

Now, the main disadvantage for me are the following implicit behavior:
- Globally available fixtures must be in a file called `conftest.py`. It took me an hour to find it.
- Using fixtures can be automatic, based on the test function argument names. E.g., a function `test_my_client(app, client)` will try to use fixtures called `app` and `client`. This can be confusing.

Honestly, for now, I took the lazy approach: don't modify every existing tests, since they use a class and `self` everywhere. So I declared some small fixtures (to make them a bit independent), and use them wherever needed.

**MagickMock and patch**: as far as I know, nothing like this exist in pytest. They have a module for monkeypatching, but the one from unittest is far more useful in my opinion, so I'll stick to that. Since `unittest` is part of standard library, we are free to use when we want to.
Yeah, I don't like implicit fixtures. If we do move to pytest, we should document/enforce that we only use explicit fixtures (like the code you propose here)

I don't understand why you need to wrap some test code with a `with self.app.app_context():`, can you explain?
Any reason why sticking to `self.assert*` unittest assertions? Nowadays pytest assertions produce legible error messages, are easier to write and easier to read, though I know not everybody shares this opinion in the python community. Is this a unit test style choice in IHM or do you plan to move on in a second step?
No, there are no reason. If we are to decide to go the pytest way, I believe it's just a migration we have to do, but which is not done yet.

@zorun, you seem to be a against using pytest. Would that be a big problem for you? Is it mainly a problem with fixtures?

On my side, I don't have a strong opinion about this. I like pytest and have been using it in other projects without trouble. I would feel more confortable with it, but I can also keep things as they currently are.
I'm not strongly opposed to pytest, I just don't like implicit fixtures. If we can avoid them, then let's move to pytest.

The other thing I find really strange is the need for the app_context sometimes, but @azmeuk mentioned that pytest-flask should solve this point.

More work for @Glandos I guess :D
> More work for @Glandos I guess :D

I can gladly help with the pytest migration if ever you don't have a lot of time to spend @Glandos.
# unittest.MagicMock and patch
Not yet identified
# flask_testing.TestCase
- Could be replaced by a fixture to create the app, as before.
- Creating the database seems to be left as an exercise for the user.

# unittest assertions
Not yet identified
It's great that you are considering moving away from Flask-Testing and exploring other options for writing tests in Flask. As you mentioned, using fixtures with pytest is a popular approach for testing Flask applications.

In addition to pytest, there are other testing frameworks that you may want to consider, such as nose or unittest2. However, pytest is widely used and offers many features that make it a popular choice among developers.

To get started with using pytest for testing Flask, you can take a look at the official Flask documentation on testing with pytest (https://flask.palletsprojects.com/en/2.1.x/testing/pytest/). This guide provides an overview of how to use pytest with Flask, including how to create test fixtures, how to write tests using the Flask test client, and how to run tests using pytest.

In general, moving to pytest involves refactoring your existing tests to use pytest fixtures and syntax. This may take some time, but it can provide significant benefits in terms of test organization, readability, and maintainability.

Some benefits of using pytest over Flask-Testing include:

Pytest offers a more powerful and flexible testing framework, with many built-in features for test discovery, test organization, and test execution.
Pytest has a large and active community, with many plugins and extensions available for customizing and extending its functionality.
Pytest is compatible with many other testing tools and frameworks, making it easy to integrate with your existing development workflow.
Overall, moving to pytest can be a great way to improve your testing process and ensure that your Flask applications are robust, reliable, and easy to maintain.

Fix patch:
diff --git a/.gitignore b/.gitignore
index 9e3c42cec..1ec6964a7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -16,4 +16,5 @@ ihatemoney/budget.db
 .DS_Store
 .idea
 .python-version
-
+.coverage*
+prof
diff --git a/setup.cfg b/setup.cfg
index 8969f262f..1adc262ea 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -59,8 +59,8 @@ dev =
     flake8==5.0.4
     isort==5.11.5
     vermin==1.5.2
-    Flask-Testing>=0.8.1
     pytest>=6.2.5
+    pytest-flask>=1.2.0
     pytest-libfaketime>=0.1.2
     tox>=3.14.6
     zest.releaser>=6.20.1


Test patch:
diff --git a/ihatemoney/tests/api_test.py b/ihatemoney/tests/api_test.py
index 73d917da1..f4c133322 100644
--- a/ihatemoney/tests/api_test.py
+++ b/ihatemoney/tests/api_test.py
@@ -1,13 +1,12 @@
 import base64
 import datetime
 import json
-import unittest
 
 from ihatemoney.tests.common.help_functions import em_surround
 from ihatemoney.tests.common.ihatemoney_testcase import IhatemoneyTestCase
 
 
-class APITestCase(IhatemoneyTestCase):
+class TestAPI(IhatemoneyTestCase):
 
     """Tests the API"""
 
@@ -57,7 +56,7 @@ def test_cors_requests(self):
         resp = self.client.options(
             "/api/projects/raclette", headers=self.get_auth("raclette")
         )
-        self.assertEqual(resp.headers["Access-Control-Allow-Origin"], "*")
+        assert resp.headers["Access-Control-Allow-Origin"] == "*"
 
     def test_basic_auth(self):
         # create a project
@@ -94,32 +93,32 @@ def test_project(self):
             },
         )
 
-        self.assertEqual(400, resp.status_code)
-        self.assertEqual(
-            '{"contact_email": ["Invalid email address."]}\n', resp.data.decode("utf-8")
+        assert 400 == resp.status_code
+        assert '{"contact_email": ["Invalid email address."]}\n' == resp.data.decode(
+            "utf-8"
         )
 
         # create it
         with self.app.mail.record_messages() as outbox:
             resp = self.api_create("raclette")
-            self.assertEqual(201, resp.status_code)
+            assert 201 == resp.status_code
 
             # Check that email messages have been sent.
-            self.assertEqual(len(outbox), 1)
-            self.assertEqual(outbox[0].recipients, ["raclette@notmyidea.org"])
+            assert len(outbox) == 1
+            assert outbox[0].recipients == ["raclette@notmyidea.org"]
 
         # create it twice should return a 400
         resp = self.api_create("raclette")
 
-        self.assertEqual(400, resp.status_code)
-        self.assertIn("id", json.loads(resp.data.decode("utf-8")))
+        assert 400 == resp.status_code
+        assert "id" in json.loads(resp.data.decode("utf-8"))
 
         # get information about it
         resp = self.client.get(
             "/api/projects/raclette", headers=self.get_auth("raclette")
         )
 
-        self.assertEqual(200, resp.status_code)
+        assert 200 == resp.status_code
         expected = {
             "members": [],
             "name": "raclette",
@@ -129,7 +128,7 @@ def test_project(self):
             "logging_preference": 1,
         }
         decoded_resp = json.loads(resp.data.decode("utf-8"))
-        self.assertDictEqual(decoded_resp, expected)
+        assert decoded_resp == expected
 
         # edit should fail if we don't provide the current private code
         resp = self.client.put(
@@ -143,7 +142,7 @@ def test_project(self):
             },
             headers=self.get_auth("raclette"),
         )
-        self.assertEqual(400, resp.status_code)
+        assert 400 == resp.status_code
 
         # edit should fail if we provide the wrong private code
         resp = self.client.put(
@@ -158,7 +157,7 @@ def test_project(self):
             },
             headers=self.get_auth("raclette"),
         )
-        self.assertEqual(400, resp.status_code)
+        assert 400 == resp.status_code
 
         # edit with the correct private code should work
         resp = self.client.put(
@@ -173,13 +172,13 @@ def test_project(self):
             },
             headers=self.get_auth("raclette"),
         )
-        self.assertEqual(200, resp.status_code)
+        assert 200 == resp.status_code
 
         resp = self.client.get(
             "/api/projects/raclette", headers=self.get_auth("raclette")
         )
 
-        self.assertEqual(200, resp.status_code)
+        assert 200 == resp.status_code
         expected = {
             "name": "The raclette party",
             "contact_email": "yeah@notmyidea.org",
@@ -189,7 +188,7 @@ def test_project(self):
             "logging_preference": 1,
         }
         decoded_resp = json.loads(resp.data.decode("utf-8"))
-        self.assertDictEqual(decoded_resp, expected)
+        assert decoded_resp == expected
 
         # password change is possible via API
         resp = self.client.put(
@@ -204,12 +203,12 @@ def test_project(self):
             headers=self.get_auth("raclette"),
         )
 
-        self.assertEqual(200, resp.status_code)
+        assert 200 == resp.status_code
 
         resp = self.client.get(
             "/api/projects/raclette", headers=self.get_auth("raclette", "tartiflette")
         )
-        self.assertEqual(200, resp.status_code)
+        assert 200 == resp.status_code
 
         # delete should work
         resp = self.client.delete(
@@ -220,21 +219,21 @@ def test_project(self):
         resp = self.client.get(
             "/api/projects/raclette", headers=self.get_auth("raclette")
         )
-        self.assertEqual(401, resp.status_code)
+        assert 401 == resp.status_code
 
     def test_token_creation(self):
         """Test that token of project is generated"""
 
         # Create project
         resp = self.api_create("raclette")
-        self.assertEqual(201, resp.status_code)
+        assert 201 == resp.status_code
 
         # Get token
         resp = self.client.get(
             "/api/projects/raclette/token", headers=self.get_auth("raclette")
         )
 
-        self.assertEqual(200, resp.status_code)
+        assert 200 == resp.status_code
 
         decoded_resp = json.loads(resp.data.decode("utf-8"))
 
@@ -243,7 +242,7 @@ def test_token_creation(self):
             "/api/projects/raclette/token",
             headers={"Authorization": f"Basic {decoded_resp['token']}"},
         )
-        self.assertEqual(200, resp.status_code)
+        assert 200 == resp.status_code
 
         # We shouldn't be able to edit project without private code
         resp = self.client.put(
@@ -256,9 +255,9 @@ def test_token_creation(self):
             },
             headers={"Authorization": f"Basic {decoded_resp['token']}"},
         )
-        self.assertEqual(400, resp.status_code)
+        assert 400 == resp.status_code
         expected_resp = {"current_password": ["This field is required."]}
-        self.assertEqual(expected_resp, json.loads(resp.data.decode("utf-8")))
+        assert expected_resp == json.loads(resp.data.decode("utf-8"))
 
     def test_token_login(self):
         resp = self.api_create("raclette")
@@ -269,7 +268,7 @@ def test_token_login(self):
         decoded_resp = json.loads(resp.data.decode("utf-8"))
         resp = self.client.get(f"/raclette/join/{decoded_resp['token']}")
         # Test that we are redirected.
-        self.assertEqual(302, resp.status_code)
+        assert 302 == resp.status_code
 
     def test_member(self):
         # create a project
@@ -281,7 +280,7 @@ def test_member(self):
         )
 
         self.assertStatus(200, req)
-        self.assertEqual("[]\n", req.data.decode("utf-8"))
+        assert "[]\n" == req.data.decode("utf-8")
 
         # add a member
         req = self.client.post(
@@ -292,7 +291,7 @@ def test_member(self):
 
         # the id of the new member should be returned
         self.assertStatus(201, req)
-        self.assertEqual("1\n", req.data.decode("utf-8"))
+        assert "1\n" == req.data.decode("utf-8")
 
         # the list of participants should contain one member
         req = self.client.get(
@@ -300,7 +299,7 @@ def test_member(self):
         )
 
         self.assertStatus(200, req)
-        self.assertEqual(len(json.loads(req.data.decode("utf-8"))), 1)
+        assert len(json.loads(req.data.decode("utf-8"))) == 1
 
         # Try to add another member with the same name.
         req = self.client.post(
@@ -325,8 +324,8 @@ def test_member(self):
         )
 
         self.assertStatus(200, req)
-        self.assertEqual("Fred", json.loads(req.data.decode("utf-8"))["name"])
-        self.assertEqual(2, json.loads(req.data.decode("utf-8"))["weight"])
+        assert "Fred" == json.loads(req.data.decode("utf-8"))["name"]
+        assert 2 == json.loads(req.data.decode("utf-8"))["weight"]
 
         # edit this member with same information
         # (test PUT idemopotence)
@@ -350,7 +349,7 @@ def test_member(self):
             "/api/projects/raclette/members/1", headers=self.get_auth("raclette")
         )
         self.assertStatus(200, req)
-        self.assertEqual(False, json.loads(req.data.decode("utf-8"))["activated"])
+        assert not json.loads(req.data.decode("utf-8"))["activated"]
 
         # re-activate the participant
         req = self.client.put(
@@ -363,7 +362,7 @@ def test_member(self):
             "/api/projects/raclette/members/1", headers=self.get_auth("raclette")
         )
         self.assertStatus(200, req)
-        self.assertEqual(True, json.loads(req.data.decode("utf-8"))["activated"])
+        assert json.loads(req.data.decode("utf-8"))["activated"]
 
         # delete a member
 
@@ -379,7 +378,7 @@ def test_member(self):
         )
 
         self.assertStatus(200, req)
-        self.assertEqual("[]\n", req.data.decode("utf-8"))
+        assert "[]\n" == req.data.decode("utf-8")
 
     def test_bills(self):
         # create a project
@@ -396,7 +395,7 @@ def test_bills(self):
         )
         self.assertStatus(200, req)
 
-        self.assertEqual("[]\n", req.data.decode("utf-8"))
+        assert "[]\n" == req.data.decode("utf-8")
 
         # add a bill
         req = self.client.post(
@@ -414,7 +413,7 @@ def test_bills(self):
 
         # should return the id
         self.assertStatus(201, req)
-        self.assertEqual(req.data.decode("utf-8"), "1\n")
+        assert req.data.decode("utf-8") == "1\n"
 
         # get this bill details
         req = self.client.get(
@@ -439,19 +438,19 @@ def test_bills(self):
         }
 
         got = json.loads(req.data.decode("utf-8"))
-        self.assertEqual(
-            datetime.date.today(),
-            datetime.datetime.strptime(got["creation_date"], "%Y-%m-%d").date(),
+        assert (
+            datetime.date.today()
+            == datetime.datetime.strptime(got["creation_date"], "%Y-%m-%d").date()
         )
         del got["creation_date"]
-        self.assertDictEqual(expected, got)
+        assert expected == got
 
         # the list of bills should length 1
         req = self.client.get(
             "/api/projects/raclette/bills", headers=self.get_auth("raclette")
         )
         self.assertStatus(200, req)
-        self.assertEqual(1, len(json.loads(req.data.decode("utf-8"))))
+        assert 1 == len(json.loads(req.data.decode("utf-8")))
 
         # edit with errors should return an error
         req = self.client.put(
@@ -468,9 +467,7 @@ def test_bills(self):
         )
 
         self.assertStatus(400, req)
-        self.assertEqual(
-            '{"date": ["This field is required."]}\n', req.data.decode("utf-8")
-        )
+        assert '{"date": ["This field is required."]}\n' == req.data.decode("utf-8")
 
         # edit a bill
         req = self.client.put(
@@ -510,12 +507,12 @@ def test_bills(self):
         }
 
         got = json.loads(req.data.decode("utf-8"))
-        self.assertEqual(
-            creation_date,
-            datetime.datetime.strptime(got["creation_date"], "%Y-%m-%d").date(),
+        assert (
+            creation_date
+            == datetime.datetime.strptime(got["creation_date"], "%Y-%m-%d").date()
         )
         del got["creation_date"]
-        self.assertDictEqual(expected, got)
+        assert expected == got
 
         # delete a bill
         req = self.client.delete(
@@ -562,7 +559,7 @@ def test_bills_with_calculation(self):
 
             # should return the id
             self.assertStatus(201, req)
-            self.assertEqual(req.data.decode("utf-8"), "{}\n".format(id))
+            assert req.data.decode("utf-8") == "{}\n".format(id)
 
             # get this bill's details
             req = self.client.get(
@@ -588,12 +585,12 @@ def test_bills_with_calculation(self):
             }
 
             got = json.loads(req.data.decode("utf-8"))
-            self.assertEqual(
-                datetime.date.today(),
-                datetime.datetime.strptime(got["creation_date"], "%Y-%m-%d").date(),
+            assert (
+                datetime.date.today()
+                == datetime.datetime.strptime(got["creation_date"], "%Y-%m-%d").date()
             )
             del got["creation_date"]
-            self.assertDictEqual(expected, got)
+            assert expected == got
 
         # should raise errors
         erroneous_amounts = [
@@ -621,19 +618,19 @@ def test_bills_with_calculation(self):
     def test_currencies(self):
         # check /currencies for list of supported currencies
         resp = self.client.get("/api/currencies")
-        self.assertEqual(200, resp.status_code)
-        self.assertIn("XXX", json.loads(resp.data.decode("utf-8")))
+        assert 200 == resp.status_code
+        assert "XXX" in json.loads(resp.data.decode("utf-8"))
 
         # create project with a default currency
         resp = self.api_create("raclette", default_currency="EUR")
-        self.assertEqual(201, resp.status_code)
+        assert 201 == resp.status_code
 
         # get information about it
         resp = self.client.get(
             "/api/projects/raclette", headers=self.get_auth("raclette")
         )
 
-        self.assertEqual(200, resp.status_code)
+        assert 200 == resp.status_code
         expected = {
             "members": [],
             "name": "raclette",
@@ -643,7 +640,7 @@ def test_currencies(self):
             "logging_preference": 1,
         }
         decoded_resp = json.loads(resp.data.decode("utf-8"))
-        self.assertDictEqual(decoded_resp, expected)
+        assert decoded_resp == expected
 
         # Add participants
         self.api_add_member("raclette", "zorglub")
@@ -666,7 +663,7 @@ def test_currencies(self):
 
         # should return the id
         self.assertStatus(201, req)
-        self.assertEqual(req.data.decode("utf-8"), "1\n")
+        assert req.data.decode("utf-8") == "1\n"
 
         # get this bill details
         req = self.client.get(
@@ -691,12 +688,12 @@ def test_currencies(self):
         }
 
         got = json.loads(req.data.decode("utf-8"))
-        self.assertEqual(
-            datetime.date.today(),
-            datetime.datetime.strptime(got["creation_date"], "%Y-%m-%d").date(),
+        assert (
+            datetime.date.today()
+            == datetime.datetime.strptime(got["creation_date"], "%Y-%m-%d").date()
         )
         del got["creation_date"]
-        self.assertDictEqual(expected, got)
+        assert expected == got
 
         # Change bill amount and currency
         req = self.client.put(
@@ -737,7 +734,7 @@ def test_currencies(self):
 
         got = json.loads(req.data.decode("utf-8"))
         del got["creation_date"]
-        self.assertDictEqual(expected, got)
+        assert expected == got
 
         # Add a bill with yet another currency
         req = self.client.post(
@@ -755,7 +752,7 @@ def test_currencies(self):
 
         # should return the id
         self.assertStatus(201, req)
-        self.assertEqual(req.data.decode("utf-8"), "2\n")
+        assert req.data.decode("utf-8") == "2\n"
 
         # Try to remove default project currency, it should fail
         req = self.client.put(
@@ -770,9 +767,9 @@ def test_currencies(self):
             headers=self.get_auth("raclette"),
         )
         self.assertStatus(400, req)
-        self.assertIn("This project cannot be set", req.data.decode("utf-8"))
-        self.assertIn(
-            "because it contains bills in multiple currencies", req.data.decode("utf-8")
+        assert "This project cannot be set" in req.data.decode("utf-8")
+        assert "because it contains bills in multiple currencies" in req.data.decode(
+            "utf-8"
         )
 
     def test_statistics(self):
@@ -801,33 +798,30 @@ def test_statistics(self):
             "/api/projects/raclette/statistics", headers=self.get_auth("raclette")
         )
         self.assertStatus(200, req)
-        self.assertEqual(
-            [
-                {
-                    "balance": 12.5,
-                    "member": {
-                        "activated": True,
-                        "id": 1,
-                        "name": "zorglub",
-                        "weight": 1.0,
-                    },
-                    "paid": 25.0,
-                    "spent": 12.5,
+        assert [
+            {
+                "balance": 12.5,
+                "member": {
+                    "activated": True,
+                    "id": 1,
+                    "name": "zorglub",
+                    "weight": 1.0,
                 },
-                {
-                    "balance": -12.5,
-                    "member": {
-                        "activated": True,
-                        "id": 2,
-                        "name": "fred",
-                        "weight": 1.0,
-                    },
-                    "paid": 0,
-                    "spent": 12.5,
+                "paid": 25.0,
+                "spent": 12.5,
+            },
+            {
+                "balance": -12.5,
+                "member": {
+                    "activated": True,
+                    "id": 2,
+                    "name": "fred",
+                    "weight": 1.0,
                 },
-            ],
-            json.loads(req.data.decode("utf-8")),
-        )
+                "paid": 0,
+                "spent": 12.5,
+            },
+        ] == json.loads(req.data.decode("utf-8"))
 
     def test_username_xss(self):
         # create a project
@@ -839,7 +833,7 @@ def test_username_xss(self):
         self.api_add_member("raclette", "<script>")
 
         result = self.client.get("/raclette/")
-        self.assertNotIn("<script>", result.data.decode("utf-8"))
+        assert "<script>" not in result.data.decode("utf-8")
 
     def test_weighted_bills(self):
         # create a project
@@ -888,12 +882,12 @@ def test_weighted_bills(self):
             "original_currency": "XXX",
         }
         got = json.loads(req.data.decode("utf-8"))
-        self.assertEqual(
-            creation_date,
-            datetime.datetime.strptime(got["creation_date"], "%Y-%m-%d").date(),
+        assert (
+            creation_date
+            == datetime.datetime.strptime(got["creation_date"], "%Y-%m-%d").date()
         )
         del got["creation_date"]
-        self.assertDictEqual(expected, got)
+        assert expected == got
 
         # getting it should return a 404
         req = self.client.get(
@@ -933,7 +927,7 @@ def test_weighted_bills(self):
 
         self.assertStatus(200, req)
         decoded_req = json.loads(req.data.decode("utf-8"))
-        self.assertDictEqual(decoded_req, expected)
+        assert decoded_req == expected
 
     def test_log_created_from_api_call(self):
         # create a project
@@ -944,15 +938,13 @@ def test_log_created_from_api_call(self):
         self.api_add_member("raclette", "zorglub")
 
         resp = self.client.get("/raclette/history", follow_redirects=True)
-        self.assertEqual(resp.status_code, 200)
-        self.assertIn(
-            f"Participant {em_surround('zorglub')} added", resp.data.decode("utf-8")
-        )
-        self.assertIn(
-            f"Project {em_surround('raclette')} added", resp.data.decode("utf-8")
+        assert resp.status_code == 200
+        assert f"Participant {em_surround('zorglub')} added" in resp.data.decode(
+            "utf-8"
         )
-        self.assertEqual(resp.data.decode("utf-8").count("<td> -- </td>"), 2)
-        self.assertNotIn("127.0.0.1", resp.data.decode("utf-8"))
+        assert f"Project {em_surround('raclette')} added" in resp.data.decode("utf-8")
+        assert resp.data.decode("utf-8").count("<td> -- </td>") == 2
+        assert "127.0.0.1" not in resp.data.decode("utf-8")
 
     def test_amount_is_null(self):
         self.api_create("raclette")
@@ -1000,7 +992,3 @@ def test_amount_too_high(self):
             headers=self.get_auth("raclette"),
         )
         self.assertStatus(400, req)
-
-
-if __name__ == "__main__":
-    unittest.main()
diff --git a/ihatemoney/tests/budget_test.py b/ihatemoney/tests/budget_test.py
index fff641ad8..241ed9cd5 100644
--- a/ihatemoney/tests/budget_test.py
+++ b/ihatemoney/tests/budget_test.py
@@ -1,7 +1,6 @@
 from collections import defaultdict
 import datetime
 import re
-import unittest
 from urllib.parse import urlparse, urlunparse
 
 from flask import session, url_for
@@ -18,7 +17,7 @@
 from ihatemoney.web import build_etag
 
 
-class BudgetTestCase(IhatemoneyTestCase):
+class TestBudget(IhatemoneyTestCase):
     def test_notifications(self):
         """Test that the notifications are sent, and that email addresses
         are checked properly.
@@ -36,11 +35,11 @@ def test_notifications(self):
             )
 
             # success notification
-            self.assertIn("Your invitations have been sent", resp.data.decode("utf-8"))
+            assert "Your invitations have been sent" in resp.data.decode("utf-8")
 
-            self.assertEqual(len(outbox), 2)
-            self.assertEqual(outbox[0].recipients, ["raclette@notmyidea.org"])
-            self.assertEqual(outbox[1].recipients, ["zorglub@notmyidea.org"])
+            assert len(outbox) == 2
+            assert outbox[0].recipients == ["raclette@notmyidea.org"]
+            assert outbox[1].recipients == ["zorglub@notmyidea.org"]
 
         # sending a message to multiple participants
         with self.app.mail.record_messages() as outbox:
@@ -50,18 +49,19 @@ def test_notifications(self):
             )
 
             # only one message is sent to multiple participants
-            self.assertEqual(len(outbox), 1)
-            self.assertEqual(
-                outbox[0].recipients, ["zorglub@notmyidea.org", "toto@notmyidea.org"]
-            )
+            assert len(outbox) == 1
+            assert outbox[0].recipients == [
+                "zorglub@notmyidea.org",
+                "toto@notmyidea.org",
+            ]
 
         # mail address checking
         with self.app.mail.record_messages() as outbox:
             response = self.client.post("/raclette/invite", data={"emails": "toto"})
-            self.assertEqual(len(outbox), 0)  # no message sent
-            self.assertIn(
-                'The email <em class="font-italic">toto</em> is not valid',
-                response.data.decode("utf-8"),
+            assert len(outbox) == 0  # no message sent
+            assert (
+                'The email <em class="font-italic">toto</em> is not valid'
+                in response.data.decode("utf-8")
             )
 
         # mail address checking for escaping
@@ -70,12 +70,11 @@ def test_notifications(self):
                 "/raclette/invite",
                 data={"emails": "<img src=x onerror=alert(document.domain)>"},
             )
-            self.assertEqual(len(outbox), 0)  # no message sent
-            self.assertIn(
+            assert len(outbox) == 0  # no message sent
+            assert (
                 'The email <em class="font-italic">'
                 "&lt;img src=x onerror=alert(document.domain)&gt;"
-                "</em> is not valid",
-                response.data.decode("utf-8"),
+                "</em> is not valid" in response.data.decode("utf-8")
             )
 
         # mixing good and wrong addresses shouldn't send any messages
@@ -85,7 +84,7 @@ def test_notifications(self):
             )  # not valid
 
             # only one message is sent to multiple participants
-            self.assertEqual(len(outbox), 0)
+            assert len(outbox) == 0
 
     def test_invite(self):
         """Test that invitation e-mails are sent properly"""
@@ -93,16 +92,16 @@ def test_invite(self):
         self.post_project("raclette")
         with self.app.mail.record_messages() as outbox:
             self.client.post("/raclette/invite", data={"emails": "toto@notmyidea.org"})
-            self.assertEqual(len(outbox), 1)
+            assert len(outbox) == 1
             url_start = outbox[0].body.find("You can log in using this link: ") + 32
             url_end = outbox[0].body.find(".\n", url_start)
             url = outbox[0].body[url_start:url_end]
         self.client.post("/exit")
         # Test that we got a valid token
         resp = self.client.get(url, follow_redirects=True)
-        self.assertIn(
-            '<a href="/raclette/members/add">Add the first participant',
-            resp.data.decode("utf-8"),
+        assert (
+            '<a href="/raclette/members/add">Add the first participant'
+            in resp.data.decode("utf-8")
         )
         # Test empty and invalid tokens
         self.client.post("/exit")
@@ -116,11 +115,11 @@ def test_invite(self):
             ),
             follow_redirects=True,
         )
-        self.assertIn("Create a new project", resp.data.decode("utf-8"))
+        assert "Create a new project" in resp.data.decode("utf-8")
 
         # A token MUST have a point between payload and signature
         resp = self.client.get("/raclette/join/token.invalid", follow_redirects=True)
-        self.assertIn("Provided token is invalid", resp.data.decode("utf-8"))
+        assert "Provided token is invalid" in resp.data.decode("utf-8")
 
     def test_create_should_remember_project(self):
         """Test that creating a project adds it to the "logged in project" list,
@@ -130,7 +129,7 @@ def test_create_should_remember_project(self):
         self.post_project("raclette")
         self.post_project("tartiflette")
         data = self.client.get("/raclette/").data.decode("utf-8")
-        self.assertEqual(data.count('href="/tartiflette/"'), 1)
+        assert data.count('href="/tartiflette/"') == 1
 
     def test_multiple_join(self):
         """Test that joining multiple times a project
@@ -146,12 +145,12 @@ def test_multiple_join(self):
         self.client.get(invite_link)
         data = self.client.get("/tartiflette/").data.decode("utf-8")
         # First join is OK
-        self.assertIn('href="/raclette/"', data)
+        assert 'href="/raclette/"' in data
 
         # Second join shouldn't add a double link
         self.client.get(invite_link)
         data = self.client.get("/tartiflette/").data.decode("utf-8")
-        self.assertEqual(data.count('href="/raclette/"'), 1)
+        assert data.count('href="/raclette/"') == 1
 
     def test_invalid_invite_link_with_feed_token(self):
         """Test that a 'feed' token is not valid to join a project"""
@@ -173,7 +172,7 @@ def test_invite_code_invalidation(self):
         self.client.post("/exit")
         response = self.client.get(link)
         # Link is valid
-        self.assertEqual(response.status_code, 302)
+        assert response.status_code == 302
 
         # Change password to invalidate token
         # Other data are required, but useless for the test
@@ -188,13 +187,13 @@ def test_invite_code_invalidation(self):
             },
             follow_redirects=True,
         )
-        self.assertEqual(response.status_code, 200)
-        self.assertNotIn("alert-danger", response.data.decode("utf-8"))
+        assert response.status_code == 200
+        assert "alert-danger" not in response.data.decode("utf-8")
 
         self.client.post("/exit")
         response = self.client.get(link, follow_redirects=True)
         # Link is invalid
-        self.assertIn("Provided token is invalid", response.data.decode("utf-8"))
+        assert "Provided token is invalid" in response.data.decode("utf-8")
 
     def test_password_reminder(self):
         # test that it is possible to have an email containing the password of a
@@ -205,13 +204,13 @@ def test_password_reminder(self):
         with self.app.mail.record_messages() as outbox:
             # a nonexisting project should not send an email
             self.client.post("/password-reminder", data={"id": "unexisting"})
-            self.assertEqual(len(outbox), 0)
+            assert len(outbox) == 0
 
             # a mail should be sent when a project exists
             self.client.post("/password-reminder", data={"id": "raclette"})
-            self.assertEqual(len(outbox), 1)
-            self.assertIn("raclette", outbox[0].body)
-            self.assertIn("raclette@notmyidea.org", outbox[0].recipients)
+            assert len(outbox) == 1
+            assert "raclette" in outbox[0].body
+            assert "raclette@notmyidea.org" in outbox[0].recipients
 
     def test_password_reset(self):
         # test that a password can be changed using a link sent by mail
@@ -223,31 +222,29 @@ def test_password_reset(self):
                 "/password-reminder", data={"id": "raclette"}, follow_redirects=True
             )
             # Check that we are redirected to the right page
-            self.assertIn(
-                "A link to reset your password has been sent to you",
-                resp.data.decode("utf-8"),
+            assert (
+                "A link to reset your password has been sent to you"
+                in resp.data.decode("utf-8")
             )
             # Check that an email was sent
-            self.assertEqual(len(outbox), 1)
+            assert len(outbox) == 1
             url_start = outbox[0].body.find("You can reset it here: ") + 23
             url_end = outbox[0].body.find(".\n", url_start)
             url = outbox[0].body[url_start:url_end]
         # Test that we got a valid token
         resp = self.client.get(url)
-        self.assertIn("Password confirmation</label>", resp.data.decode("utf-8"))
+        assert "Password confirmation</label>" in resp.data.decode("utf-8")
         # Test that password can be changed
         self.client.post(
             url, data={"password": "pass", "password_confirmation": "pass"}
         )
         resp = self.login("raclette", password="pass")
-        self.assertIn(
-            "<title>Account manager - raclette</title>", resp.data.decode("utf-8")
-        )
+        assert "<title>Account manager - raclette</title>" in resp.data.decode("utf-8")
         # Test empty and null tokens
         resp = self.client.get("/reset-password")
-        self.assertIn("No token provided", resp.data.decode("utf-8"))
+        assert "No token provided" in resp.data.decode("utf-8")
         resp = self.client.get("/reset-password?token=token")
-        self.assertIn("Invalid token", resp.data.decode("utf-8"))
+        assert "Invalid token" in resp.data.decode("utf-8")
 
     def test_project_creation(self):
         with self.client as c:
@@ -266,18 +263,17 @@ def test_project_creation(self):
                 )
 
                 # An email is sent to the owner with a reminder of the password.
-                self.assertEqual(len(outbox), 1)
-                self.assertEqual(outbox[0].recipients, ["raclette@notmyidea.org"])
-                self.assertIn(
-                    "A reminder email has just been sent to you",
-                    resp.data.decode("utf-8"),
+                assert len(outbox) == 1
+                assert outbox[0].recipients == ["raclette@notmyidea.org"]
+                assert "A reminder email has just been sent to you" in resp.data.decode(
+                    "utf-8"
                 )
 
             # session is updated
-            self.assertTrue(session["raclette"])
+            assert session["raclette"]
 
             # project is created
-            self.assertEqual(len(models.Project.query.all()), 1)
+            assert len(models.Project.query.all()) == 1
 
             # Add a second project with the same id
             self.get_project("raclette")
@@ -294,7 +290,7 @@ def test_project_creation(self):
             )
 
             # no new project added
-            self.assertEqual(len(models.Project.query.all()), 1)
+            assert len(models.Project.query.all()) == 1
 
     def test_project_creation_without_public_permissions(self):
         self.app.config["ALLOW_PUBLIC_PROJECT_CREATION"] = False
@@ -312,10 +308,10 @@ def test_project_creation_without_public_permissions(self):
             )
 
             # session is not updated
-            self.assertNotIn("raclette", session)
+            assert "raclette" not in session
 
             # project is not created
-            self.assertEqual(len(models.Project.query.all()), 0)
+            assert len(models.Project.query.all()) == 0
 
     def test_project_creation_with_public_permissions(self):
         self.app.config["ALLOW_PUBLIC_PROJECT_CREATION"] = True
@@ -333,10 +329,10 @@ def test_project_creation_with_public_permissions(self):
             )
 
             # session is updated
-            self.assertTrue(session["raclette"])
+            assert session["raclette"]
 
             # project is created
-            self.assertEqual(len(models.Project.query.all()), 1)
+            assert len(models.Project.query.all()) == 1
 
     def test_project_deletion(self):
         with self.client as c:
@@ -352,14 +348,14 @@ def test_project_deletion(self):
             )
 
             # project added
-            self.assertEqual(len(models.Project.query.all()), 1)
+            assert len(models.Project.query.all()) == 1
 
             # Check that we can't delete project with a GET or with a
             # password-less POST.
             resp = self.client.get("/raclette/delete")
-            self.assertEqual(resp.status_code, 405)
+            assert resp.status_code == 405
             self.client.post("/raclette/delete")
-            self.assertEqual(len(models.Project.query.all()), 1)
+            assert len(models.Project.query.all()) == 1
 
             # Delete for real
             c.post(
@@ -368,7 +364,7 @@ def test_project_deletion(self):
             )
 
             # project removed
-            self.assertEqual(len(models.Project.query.all()), 0)
+            assert len(models.Project.query.all()) == 0
 
     def test_bill_placeholder(self):
         self.post_project("raclette")
@@ -377,9 +373,9 @@ def test_bill_placeholder(self):
         result = self.client.get("/raclette/")
 
         # Empty bill list and no participant, should now propose to add participants first
-        self.assertIn(
-            '<a href="/raclette/members/add">Add the first participant',
-            result.data.decode("utf-8"),
+        assert (
+            '<a href="/raclette/members/add">Add the first participant'
+            in result.data.decode("utf-8")
         )
 
         result = self.client.post("/raclette/members/add", data={"name": "zorglub"})
@@ -387,8 +383,8 @@ def test_bill_placeholder(self):
         result = self.client.get("/raclette/")
 
         # Empty bill with member, list should now propose to add bills
-        self.assertIn('<a href="/raclette/add"', result.data.decode("utf-8"))
-        self.assertIn("Add your first bill", result.data.decode("utf-8"))
+        assert '<a href="/raclette/add"' in result.data.decode("utf-8")
+        assert "Add your first bill" in result.data.decode("utf-8")
 
     def test_membership(self):
         self.post_project("raclette")
@@ -396,21 +392,21 @@ def test_membership(self):
 
         # adds a member to this project
         self.client.post("/raclette/members/add", data={"name": "zorglub"})
-        self.assertEqual(len(self.get_project("raclette").members), 1)
+        assert len(self.get_project("raclette").members) == 1
 
         # adds him twice
         result = self.client.post("/raclette/members/add", data={"name": "zorglub"})
 
         # should not accept him
-        self.assertEqual(len(self.get_project("raclette").members), 1)
+        assert len(self.get_project("raclette").members) == 1
 
         # add fred
         self.client.post("/raclette/members/add", data={"name": "fred"})
-        self.assertEqual(len(self.get_project("raclette").members), 2)
+        assert len(self.get_project("raclette").members) == 2
 
         # check fred is present in the bills page
         result = self.client.get("/raclette/")
-        self.assertIn("fred", result.data.decode("utf-8"))
+        assert "fred" in result.data.decode("utf-8")
 
         # remove fred
         self.client.post(
@@ -418,7 +414,7 @@ def test_membership(self):
         )
 
         # as fred is not bound to any bill, he is removed
-        self.assertEqual(len(self.get_project("raclette").members), 1)
+        assert len(self.get_project("raclette").members) == 1
 
         # add fred again
         self.client.post("/raclette/members/add", data={"name": "fred"})
@@ -440,29 +436,29 @@ def test_membership(self):
         self.client.post(f"/raclette/members/{fred_id}/delete")
 
         # he is still in the database, but is deactivated
-        self.assertEqual(len(self.get_project("raclette").members), 2)
-        self.assertEqual(len(self.get_project("raclette").active_members), 1)
+        assert len(self.get_project("raclette").members) == 2
+        assert len(self.get_project("raclette").active_members) == 1
 
         # as fred is now deactivated, check that he is not listed when adding
         # a bill or displaying the balance
         result = self.client.get("/raclette/")
-        self.assertNotIn(
-            (f"/raclette/members/{fred_id}/delete"), result.data.decode("utf-8")
+        assert (f"/raclette/members/{fred_id}/delete") not in result.data.decode(
+            "utf-8"
         )
 
         result = self.client.get("/raclette/add")
-        self.assertNotIn("fred", result.data.decode("utf-8"))
+        assert "fred" not in result.data.decode("utf-8")
 
         # adding him again should reactivate him
         self.client.post("/raclette/members/add", data={"name": "fred"})
-        self.assertEqual(len(self.get_project("raclette").active_members), 2)
+        assert len(self.get_project("raclette").active_members) == 2
 
         # adding an user with the same name as another user from a different
         # project should not cause any troubles
         self.post_project("randomid")
         self.login("randomid")
         self.client.post("/randomid/members/add", data={"name": "fred"})
-        self.assertEqual(len(self.get_project("randomid").active_members), 1)
+        assert len(self.get_project("randomid").active_members) == 1
 
     def test_person_model(self):
         self.post_project("raclette")
@@ -473,7 +469,7 @@ def test_person_model(self):
         zorglub = self.get_project("raclette").members[-1]
 
         # should not have any bills
-        self.assertFalse(zorglub.has_bills())
+        assert not zorglub.has_bills()
 
         # bound him to a bill
         self.client.post(
@@ -489,7 +485,7 @@ def test_person_model(self):
 
         # should have a bill now
         zorglub = self.get_project("raclette").members[-1]
-        self.assertTrue(zorglub.has_bills())
+        assert zorglub.has_bills()
 
     def test_member_delete_method(self):
         self.post_project("raclette")
@@ -500,36 +496,36 @@ def test_member_delete_method(self):
 
         # try to remove the member using GET method
         response = self.client.get("/raclette/members/1/delete")
-        self.assertEqual(response.status_code, 405)
+        assert response.status_code == 405
 
         # delete user using POST method
         self.client.post("/raclette/members/1/delete")
-        self.assertEqual(len(self.get_project("raclette").active_members), 0)
+        assert len(self.get_project("raclette").active_members) == 0
         # try to delete an user already deleted
         self.client.post("/raclette/members/1/delete")
 
     def test_demo(self):
         # test that a demo project is created if none is defined
-        self.assertEqual([], models.Project.query.all())
+        assert [] == models.Project.query.all()
         self.client.get("/demo")
         demo = self.get_project("demo")
-        self.assertTrue(demo is not None)
+        assert demo is not None
 
-        self.assertEqual(["Amina", "Georg", "Alice"], [m.name for m in demo.members])
-        self.assertEqual(demo.get_bills().count(), 3)
+        assert ["Amina", "Georg", "Alice"] == [m.name for m in demo.members]
+        assert demo.get_bills().count() == 3
 
     def test_deactivated_demo(self):
         self.app.config["ACTIVATE_DEMO_PROJECT"] = False
 
         # test redirection to the create project form when demo is deactivated
         resp = self.client.get("/demo")
-        self.assertIn('<a href="/create?project_id=demo">', resp.data.decode("utf-8"))
+        assert '<a href="/create?project_id=demo">' in resp.data.decode("utf-8")
 
     def test_authentication(self):
         # try to authenticate without credentials should redirect
         # to the authentication page
         resp = self.client.post("/authenticate")
-        self.assertIn("Authentication", resp.data.decode("utf-8"))
+        assert "Authentication" in resp.data.decode("utf-8")
 
         # raclette that the login / logout process works
         self.create_project("raclette")
@@ -537,14 +533,14 @@ def test_authentication(self):
         # try to see the project while not being authenticated should redirect
         # to the authentication page
         resp = self.client.get("/raclette", follow_redirects=True)
-        self.assertIn("Authentication", resp.data.decode("utf-8"))
+        assert "Authentication" in resp.data.decode("utf-8")
 
         # try to connect with wrong credentials should not work
         with self.client as c:
             resp = c.post("/authenticate", data={"id": "raclette", "password": "nope"})
 
-            self.assertIn("Authentication", resp.data.decode("utf-8"))
-            self.assertNotIn("raclette", session)
+            assert "Authentication" in resp.data.decode("utf-8")
+            assert "raclette" not in session
 
         # try to connect with the right credentials should work
         with self.client as c:
@@ -552,9 +548,9 @@ def test_authentication(self):
                 "/authenticate", data={"id": "raclette", "password": "raclette"}
             )
 
-            self.assertNotIn("Authentication", resp.data.decode("utf-8"))
-            self.assertIn("raclette", session)
-            self.assertTrue(session["raclette"])
+            assert "Authentication" not in resp.data.decode("utf-8")
+            assert "raclette" in session
+            assert session["raclette"]
 
             # logout should work with POST only
             resp = c.get("/exit")
@@ -562,14 +558,14 @@ def test_authentication(self):
 
             # logout should wipe the session out
             c.post("/exit")
-            self.assertNotIn("raclette", session)
+            assert "raclette" not in session
 
         # test that with admin credentials, one can access every project
         self.app.config["ADMIN_PASSWORD"] = generate_password_hash("pass")
         with self.client as c:
             resp = c.post("/admin?goto=%2Fraclette", data={"admin_password": "pass"})
-            self.assertNotIn("Authentication", resp.data.decode("utf-8"))
-            self.assertTrue(session["is_admin"])
+            assert "Authentication" not in resp.data.decode("utf-8")
+            assert session["is_admin"]
 
     def test_authentication_with_upper_case(self):
         self.post_project("Raclette")
@@ -580,9 +576,9 @@ def test_authentication_with_upper_case(self):
                 "/authenticate", data={"id": "Raclette", "password": "Raclette"}
             )
 
-            self.assertNotIn("Authentication", resp.data.decode("utf-8"))
-            self.assertIn("raclette", session)
-            self.assertTrue(session["raclette"])
+            assert "Authentication" not in resp.data.decode("utf-8")
+            assert "raclette" in session
+            assert session["raclette"]
 
     def test_admin_authentication(self):
         self.app.config["ADMIN_PASSWORD"] = generate_password_hash("pass")
@@ -591,23 +587,23 @@ def test_admin_authentication(self):
 
         # test the redirection to the authentication page when trying to access admin endpoints
         resp = self.client.get("/create")
-        self.assertIn('<a href="/admin?goto=%2Fcreate">', resp.data.decode("utf-8"))
+        assert '<a href="/admin?goto=%2Fcreate">' in resp.data.decode("utf-8")
 
         # test right password
         resp = self.client.post(
             "/admin?goto=%2Fcreate", data={"admin_password": "pass"}
         )
-        self.assertIn('<a href="/create">/create</a>', resp.data.decode("utf-8"))
+        assert '<a href="/create">/create</a>' in resp.data.decode("utf-8")
 
         # test wrong password
         resp = self.client.post(
             "/admin?goto=%2Fcreate", data={"admin_password": "wrong"}
         )
-        self.assertNotIn('<a href="/create">/create</a>', resp.data.decode("utf-8"))
+        assert '<a href="/create">/create</a>' not in resp.data.decode("utf-8")
 
         # test empty password
         resp = self.client.post("/admin?goto=%2Fcreate", data={"admin_password": ""})
-        self.assertNotIn('<a href="/create">/create</a>', resp.data.decode("utf-8"))
+        assert '<a href="/create">/create</a>' not in resp.data.decode("utf-8")
 
     def test_login_throttler(self):
         self.app.config["ADMIN_PASSWORD"] = generate_password_hash("pass")
@@ -620,10 +616,7 @@ def test_login_throttler(self):
             "/admin?goto=%2Fcreate", data={"admin_password": "wrong"}
         )
 
-        self.assertIn(
-            "Too many failed login attempts.",
-            resp.data.decode("utf-8"),
-        )
+        assert "Too many failed login attempts." in resp.data.decode("utf-8")
         # Try with limiter disabled
         from ihatemoney.utils import limiter
 
@@ -632,10 +625,7 @@ def test_login_throttler(self):
             resp = self.client.post(
                 "/admin?goto=%2Fcreate", data={"admin_password": "wrong"}
             )
-            self.assertNotIn(
-                "Too many failed login attempts.",
-                resp.data.decode("utf-8"),
-            )
+            assert "Too many failed login attempts." not in resp.data.decode("utf-8")
         finally:
             limiter.enabled = True
 
@@ -661,7 +651,7 @@ def test_manage_bills(self):
         )
         self.get_project("raclette")
         bill = models.Bill.query.one()
-        self.assertEqual(bill.amount, 25)
+        assert bill.amount == 25
 
         # edit the bill
         self.client.post(
@@ -676,15 +666,15 @@ def test_manage_bills(self):
         )
 
         bill = models.Bill.query.one()
-        self.assertEqual(bill.amount, 10, "bill edition")
+        assert bill.amount == 10, "bill edition"
 
         # Try to delete the bill with a GET: it should fail
         response = self.client.get(f"/raclette/delete/{bill.id}")
-        self.assertEqual(response.status_code, 405)
-        self.assertEqual(1, len(models.Bill.query.all()), "bill deletion")
+        assert response.status_code == 405
+        assert 1 == len(models.Bill.query.all()), "bill deletion"
         # Really delete the bill
         self.client.post(f"/raclette/delete/{bill.id}")
-        self.assertEqual(0, len(models.Bill.query.all()), "bill deletion")
+        assert 0 == len(models.Bill.query.all()), "bill deletion"
 
         # test balance
         self.client.post(
@@ -721,7 +711,7 @@ def test_manage_bills(self):
         )
 
         balance = self.get_project("raclette").balance
-        self.assertEqual(set(balance.values()), set([19.0, -19.0]))
+        assert set(balance.values()) == set([19.0, -19.0])
 
         # Bill with negative amount
         self.client.post(
@@ -735,7 +725,7 @@ def test_manage_bills(self):
             },
         )
         bill = models.Bill.query.filter(models.Bill.date == "2011-08-12")[0]
-        self.assertEqual(bill.amount, -25)
+        assert bill.amount == -25
 
         # add a bill with a comma
         self.client.post(
@@ -749,7 +739,7 @@ def test_manage_bills(self):
             },
         )
         bill = models.Bill.query.filter(models.Bill.date == "2011-08-01")[0]
-        self.assertEqual(bill.amount, 25.02)
+        assert bill.amount == 25.02
 
         # add a bill with a valid external link
         self.client.post(
@@ -764,7 +754,7 @@ def test_manage_bills(self):
             },
         )
         bill = models.Bill.query.filter(models.Bill.date == "2015-05-05")[0]
-        self.assertEqual(bill.external_link, "https://example.com/fromage")
+        assert bill.external_link == "https://example.com/fromage"
 
         # add a bill with an invalid external link
         resp = self.client.post(
@@ -778,7 +768,7 @@ def test_manage_bills(self):
                 "external_link": "javascript:alert('Tu bluffes, Martoni.')",
             },
         )
-        self.assertIn("Invalid URL", resp.data.decode("utf-8"))
+        assert "Invalid URL" in resp.data.decode("utf-8")
 
     def test_weighted_balance(self):
         self.post_project("raclette")
@@ -815,7 +805,7 @@ def test_weighted_balance(self):
         )
 
         balance = self.get_project("raclette").balance
-        self.assertEqual(set(balance.values()), set([6, -6]))
+        assert set(balance.values()) == set([6, -6])
 
     def test_trimmed_members(self):
         self.post_project("raclette")
@@ -825,7 +815,7 @@ def test_trimmed_members(self):
         self.client.post("/raclette/members/add", data={"name": "zorglub "})
         members = self.get_project("raclette").members
 
-        self.assertEqual(len(members), 1)
+        assert len(members) == 1
 
     def test_weighted_members_list(self):
         self.post_project("raclette")
@@ -835,14 +825,14 @@ def test_weighted_members_list(self):
         self.client.post("/raclette/members/add", data={"name": "tata", "weight": 1})
 
         resp = self.client.get("/raclette/")
-        self.assertIn("extra-info", resp.data.decode("utf-8"))
+        assert "extra-info" in resp.data.decode("utf-8")
 
         self.client.post(
             "/raclette/members/add", data={"name": "freddy familly", "weight": 4}
         )
 
         resp = self.client.get("/raclette/")
-        self.assertNotIn("extra-info", resp.data.decode("utf-8"))
+        assert "extra-info" not in resp.data.decode("utf-8")
 
     def test_negative_weight(self):
         self.post_project("raclette")
@@ -854,9 +844,9 @@ def test_negative_weight(self):
         )
 
         # An error should be generated, and its weight should still be 1.
-        self.assertIn('<p class="alert alert-danger">', resp.data.decode("utf-8"))
-        self.assertEqual(len(self.get_project("raclette").members), 1)
-        self.assertEqual(self.get_project("raclette").members[0].weight, 1)
+        assert '<p class="alert alert-danger">' in resp.data.decode("utf-8")
+        assert len(self.get_project("raclette").members) == 1
+        assert self.get_project("raclette").members[0].weight == 1
 
     def test_rounding(self):
         self.post_project("raclette")
@@ -910,7 +900,7 @@ def test_rounding(self):
         # However, we should obtain the same values as the theoretical ones if we
         # round to 2 decimals, like in the UI.
         for key, value in balance.items():
-            self.assertEqual(round(value, 2), result[key])
+            assert round(value, 2) == result[key]
 
     def test_edit_project(self):
         # A project should be editable
@@ -926,38 +916,38 @@ def test_edit_project(self):
 
         # It should fail if we don't provide the current password
         resp = self.client.post("/raclette/edit", data=new_data, follow_redirects=False)
-        self.assertIn("This field is required", resp.data.decode("utf-8"))
+        assert "This field is required" in resp.data.decode("utf-8")
         project = self.get_project("raclette")
-        self.assertNotEqual(project.name, new_data["name"])
-        self.assertNotEqual(project.contact_email, new_data["contact_email"])
-        self.assertNotEqual(project.default_currency, new_data["default_currency"])
-        self.assertFalse(check_password_hash(project.password, new_data["password"]))
+        assert project.name != new_data["name"]
+        assert project.contact_email != new_data["contact_email"]
+        assert project.default_currency != new_data["default_currency"]
+        assert not check_password_hash(project.password, new_data["password"])
 
         # It should fail if we provide the wrong current password
         new_data["current_password"] = "patates au fromage"
         resp = self.client.post("/raclette/edit", data=new_data, follow_redirects=False)
-        self.assertIn("Invalid private code", resp.data.decode("utf-8"))
+        assert "Invalid private code" in resp.data.decode("utf-8")
         project = self.get_project("raclette")
-        self.assertNotEqual(project.name, new_data["name"])
-        self.assertNotEqual(project.contact_email, new_data["contact_email"])
-        self.assertNotEqual(project.default_currency, new_data["default_currency"])
-        self.assertFalse(check_password_hash(project.password, new_data["password"]))
+        assert project.name != new_data["name"]
+        assert project.contact_email != new_data["contact_email"]
+        assert project.default_currency != new_data["default_currency"]
+        assert not check_password_hash(project.password, new_data["password"])
 
         # It should work if we give the current private code
         new_data["current_password"] = "raclette"
         resp = self.client.post("/raclette/edit", data=new_data)
-        self.assertEqual(resp.status_code, 302)
+        assert resp.status_code == 302
         project = self.get_project("raclette")
-        self.assertEqual(project.name, new_data["name"])
-        self.assertEqual(project.contact_email, new_data["contact_email"])
-        self.assertEqual(project.default_currency, new_data["default_currency"])
-        self.assertTrue(check_password_hash(project.password, new_data["password"]))
+        assert project.name == new_data["name"]
+        assert project.contact_email == new_data["contact_email"]
+        assert project.default_currency == new_data["default_currency"]
+        assert check_password_hash(project.password, new_data["password"])
 
         # Editing a project with a wrong email address should fail
         new_data["contact_email"] = "wrong_email"
 
         resp = self.client.post("/raclette/edit", data=new_data)
-        self.assertIn("Invalid email address", resp.data.decode("utf-8"))
+        assert "Invalid email address" in resp.data.decode("utf-8")
 
     def test_dashboard(self):
         # test that the dashboard is deactivated by default
@@ -966,17 +956,16 @@ def test_dashboard(self):
             data={"admin_password": "adminpass"},
             follow_redirects=True,
         )
-        self.assertIn('<div class="alert alert-danger">', resp.data.decode("utf-8"))
+        assert '<div class="alert alert-danger">' in resp.data.decode("utf-8")
 
         # test access to the dashboard when it is activated
         self.enable_admin()
         resp = self.client.get("/dashboard")
-        self.assertIn(
-            """<thead>
+        assert """<thead>
         <tr>
             <th>Project</th>
-            <th>Number of participants</th>""",
-            resp.data.decode("utf-8"),
+            <th>Number of participants</th>""" in resp.data.decode(
+            "utf-8"
         )
 
     def test_dashboard_project_deletion(self):
@@ -985,18 +974,18 @@ def test_dashboard_project_deletion(self):
         resp = self.client.get("/dashboard")
         pattern = re.compile(r"<form id=\"delete-project\" [^>]*?action=\"(.*?)\"")
         match = pattern.search(resp.data.decode("utf-8"))
-        self.assertIsNotNone(match)
-        self.assertIsNotNone(match.group(1))
+        assert match is not None
+        assert match.group(1) is not None
 
         resp = self.client.post(match.group(1))
 
         # project removed
-        self.assertEqual(len(models.Project.query.all()), 0)
+        assert len(models.Project.query.all()) == 0
 
     def test_statistics_page(self):
         self.post_project("raclette")
         response = self.client.get("/raclette/statistics")
-        self.assertEqual(response.status_code, 200)
+        assert response.status_code == 200
 
     def test_statistics(self):
         # Output is checked with the USD sign
@@ -1011,8 +1000,8 @@ def test_statistics(self):
 
         # Check that there are no monthly statistics and no active months
         project = self.get_project("raclette")
-        self.assertEqual(len(project.active_months_range()), 0)
-        self.assertEqual(len(project.monthly_stats), 0)
+        assert len(project.active_months_range()) == 0
+        assert len(project.monthly_stats) == 0
 
         # Check that the "monthly expenses" table is empty
         response = self.client.get("/raclette/statistics")
@@ -1020,7 +1009,7 @@ def test_statistics(self):
             r"<table id=\"monthly_stats\".*>\s*<thead>\s*<tr>\s*<th>Period</th>\s*"
             r"<th>Spent</th>\s*</tr>\s*</thead>\s*<tbody>\s*</tbody>\s*</table>"
         )
-        self.assertRegex(response.data.decode("utf-8"), regex)
+        assert re.search(regex, response.data.decode("utf-8"))
 
         # create bills
         self.client.post(
@@ -1058,19 +1047,18 @@ def test_statistics(self):
 
         response = self.client.get("/raclette/statistics")
         regex = r"<td class=\"d-md-none\">{}</td>\s*<td>{}</td>\s*<td>{}</td>"
-        self.assertRegex(
-            response.data.decode("utf-8"),
+        assert re.search(
             regex.format("zorglub", r"\$20\.00", r"\$31\.67"),
-        )
-        self.assertRegex(
             response.data.decode("utf-8"),
-            regex.format("fred", r"\$20\.00", r"\$5\.83"),
         )
-        self.assertRegex(
-            response.data.decode("utf-8"), regex.format("tata", r"\$0\.00", r"\$2\.50")
+        assert re.search(
+            regex.format("fred", r"\$20\.00", r"\$5\.83"), response.data.decode("utf-8")
         )
-        self.assertRegex(
-            response.data.decode("utf-8"), regex.format("pépé", r"\$0\.00", r"\$0\.00")
+        assert re.search(
+            regex.format("tata", r"\$0\.00", r"\$2\.50"), response.data.decode("utf-8")
+        )
+        assert re.search(
+            regex.format("pépé", r"\$0\.00", r"\$0\.00"), response.data.decode("utf-8")
         )
 
         # Check that the order of participants in the sidebar table is the
@@ -1084,13 +1072,13 @@ def test_statistics(self):
         )
         # Build the regexp ourselves to be able to pass the DOTALL flag
         # (so that ".*" matches newlines)
-        self.assertRegex(response.data.decode("utf-8"), re.compile(regex1, re.DOTALL))
-        self.assertRegex(response.data.decode("utf-8"), re.compile(regex2, re.DOTALL))
+        assert re.search(re.compile(regex1, re.DOTALL), response.data.decode("utf-8"))
+        assert re.search(re.compile(regex2, re.DOTALL), response.data.decode("utf-8"))
 
         # Check monthly expenses again: it should have a single month and the correct amount
         august = datetime.date(year=2011, month=8, day=1)
-        self.assertEqual(project.active_months_range(), [august])
-        self.assertEqual(dict(project.monthly_stats[2011]), {8: 40.0})
+        assert project.active_months_range() == [august]
+        assert dict(project.monthly_stats[2011]) == {8: 40.0}
 
         # Add bills for other months and check monthly expenses again
         self.client.post(
@@ -1114,8 +1102,8 @@ def test_statistics(self):
             12: 30.0,
             8: 40.0,
         }
-        self.assertEqual(project.active_months_range(), months)
-        self.assertEqual(dict(project.monthly_stats[2011]), amounts_2011)
+        assert project.active_months_range() == months
+        assert dict(project.monthly_stats[2011]) == amounts_2011
 
         # Test more corner cases: first day of month as oldest bill
         self.client.post(
@@ -1129,8 +1117,8 @@ def test_statistics(self):
             },
         )
         amounts_2011[8] += 10.0
-        self.assertEqual(project.active_months_range(), months)
-        self.assertEqual(dict(project.monthly_stats[2011]), amounts_2011)
+        assert project.active_months_range() == months
+        assert dict(project.monthly_stats[2011]) == amounts_2011
 
         # Last day of month as newest bill
         self.client.post(
@@ -1144,8 +1132,8 @@ def test_statistics(self):
             },
         )
         amounts_2011[12] += 10.0
-        self.assertEqual(project.active_months_range(), months)
-        self.assertEqual(dict(project.monthly_stats[2011]), amounts_2011)
+        assert project.active_months_range() == months
+        assert dict(project.monthly_stats[2011]) == amounts_2011
 
         # Last day of month as oldest bill
         self.client.post(
@@ -1160,8 +1148,8 @@ def test_statistics(self):
         )
         months.append(datetime.date(year=2011, month=7, day=1))
         amounts_2011[7] = 20.0
-        self.assertEqual(project.active_months_range(), months)
-        self.assertEqual(dict(project.monthly_stats[2011]), amounts_2011)
+        assert project.active_months_range() == months
+        assert dict(project.monthly_stats[2011]) == amounts_2011
 
         # First day of month as newest bill
         self.client.post(
@@ -1176,14 +1164,14 @@ def test_statistics(self):
         )
         months.insert(0, datetime.date(year=2012, month=1, day=1))
         amounts_2012 = {1: 30.0}
-        self.assertEqual(project.active_months_range(), months)
-        self.assertEqual(dict(project.monthly_stats[2011]), amounts_2011)
-        self.assertEqual(dict(project.monthly_stats[2012]), amounts_2012)
+        assert project.active_months_range() == months
+        assert dict(project.monthly_stats[2011]) == amounts_2011
+        assert dict(project.monthly_stats[2012]) == amounts_2012
 
     def test_settle_page(self):
         self.post_project("raclette")
         response = self.client.get("/raclette/settle_bills")
-        self.assertEqual(response.status_code, 200)
+        assert response.status_code == 200
 
     def test_settle(self):
         self.post_project("raclette")
@@ -1237,7 +1225,7 @@ def test_settle(self):
             members[t["receiver"]] += t["amount"]
         balance = self.get_project("raclette").balance
         for m, a in members.items():
-            self.assertAlmostEqual(a, balance[m.id], delta=0.01)
+            assert abs(a - balance[m.id]) < 0.01
         return
 
     def test_settle_zero(self):
@@ -1287,11 +1275,9 @@ def test_settle_zero(self):
         # There should not be any zero-amount transfer after rounding
         for t in transactions:
             rounded_amount = round(t["amount"], 2)
-            self.assertNotEqual(
-                0.0,
-                rounded_amount,
-                msg=f"{t['amount']} is equal to zero after rounding",
-            )
+            assert (
+                0.0 != rounded_amount
+            ), f"{t['amount']} is equal to zero after rounding"
 
     def test_access_other_projects(self):
         """Test that accessing or editing bills and participants from another project fails"""
@@ -1317,7 +1303,7 @@ def test_access_other_projects(self):
         )
         # Ensure it has been created
         raclette = self.get_project("raclette")
-        self.assertEqual(raclette.get_bills().count(), 1)
+        assert raclette.get_bills().count() == 1
 
         # Log out
         self.client.post("/exit")
@@ -1353,7 +1339,7 @@ def test_access_other_projects(self):
 
         # Additional check that the bill was indeed not modified or deleted
         bill = models.Bill.query.filter(models.Bill.id == 1).one()
-        self.assertEqual(bill.what, "fromage à raclette")
+        assert bill.what == "fromage à raclette"
 
         # Use the correct credentials to modify and delete the bill.
         # This ensures that modifying and deleting the bill can actually work
@@ -1364,11 +1350,11 @@ def test_access_other_projects(self):
         )
         self.client.post("/raclette/edit/1", data=modified_bill)
         bill = models.Bill.query.filter(models.Bill.id == 1).one_or_none()
-        self.assertNotEqual(bill, None, "bill not found")
-        self.assertEqual(bill.what, "roblochon")
+        assert bill is not None, "bill not found"
+        assert bill.what == "roblochon"
         self.client.post("/raclette/delete/1")
         bill = models.Bill.query.filter(models.Bill.id == 1).one_or_none()
-        self.assertEqual(bill, None)
+        assert bill is None
 
         # Switch back to the second project
         self.client.post("/exit")
@@ -1400,9 +1386,9 @@ def test_access_other_projects(self):
 
         # Additional check that the member was indeed not modified or deleted
         member = models.Person.query.filter(models.Person.id == 1).one_or_none()
-        self.assertNotEqual(member, None, "member not found")
-        self.assertEqual(member.name, "zorglub")
-        self.assertTrue(member.activated)
+        assert member is not None, "member not found"
+        assert member.name == "zorglub"
+        assert member.activated
 
         # Use the correct credentials to modify and delete the member.
         # This ensures that modifying and deleting the member can actually work
@@ -1412,10 +1398,10 @@ def test_access_other_projects(self):
         )
         self.client.post("/raclette/members/1/edit", data=modified_member)
         member = models.Person.query.filter(models.Person.id == 1).one()
-        self.assertEqual(member.name, "bulgroz")
+        assert member.name == "bulgroz"
         self.client.post("/raclette/members/1/delete")
         member = models.Person.query.filter(models.Person.id == 1).one_or_none()
-        self.assertEqual(member, None)
+        assert member is None
 
     def test_currency_switch(self):
         # A project should be editable
@@ -1464,14 +1450,14 @@ def test_currency_switch(self):
 
         # First all converted_amount should be the same as amount, with no currency
         for bill in project.get_bills():
-            self.assertEqual(bill.original_currency, CurrencyConverter.no_currency)
-            self.assertEqual(bill.amount, bill.converted_amount)
+            assert bill.original_currency == CurrencyConverter.no_currency
+            assert bill.amount == bill.converted_amount
 
         # Then, switch to EUR, all bills must have been changed to this currency
         project.switch_currency("EUR")
         for bill in project.get_bills():
-            self.assertEqual(bill.original_currency, "EUR")
-            self.assertEqual(bill.amount, bill.converted_amount)
+            assert bill.original_currency == "EUR"
+            assert bill.amount == bill.converted_amount
 
         # Add a bill in EUR, the current default currency
         self.client.post(
@@ -1486,13 +1472,13 @@ def test_currency_switch(self):
             },
         )
         last_bill = project.get_bills().first()
-        self.assertEqual(last_bill.converted_amount, last_bill.amount)
+        assert last_bill.converted_amount == last_bill.amount
 
         # Erase all currencies
         project.switch_currency(CurrencyConverter.no_currency)
         for bill in project.get_bills():
-            self.assertEqual(bill.original_currency, CurrencyConverter.no_currency)
-            self.assertEqual(bill.amount, bill.converted_amount)
+            assert bill.original_currency == CurrencyConverter.no_currency
+            assert bill.amount == bill.converted_amount
 
         # Let's go back to EUR to test conversion
         project.switch_currency("EUR")
@@ -1512,16 +1498,16 @@ def test_currency_switch(self):
         expected_amount = self.converter.exchange_currency(
             last_bill.amount, "CAD", "EUR"
         )
-        self.assertEqual(last_bill.converted_amount, expected_amount)
+        assert last_bill.converted_amount == expected_amount
 
         # Switch to USD. Now, NO bill should be in USD, since they already had a currency
         project.switch_currency("USD")
         for bill in project.get_bills():
-            self.assertNotEqual(bill.original_currency, "USD")
+            assert bill.original_currency != "USD"
             expected_amount = self.converter.exchange_currency(
                 bill.amount, bill.original_currency, "USD"
             )
-            self.assertEqual(bill.converted_amount, expected_amount)
+            assert bill.converted_amount == expected_amount
 
         # Switching back to no currency must fail
         with pytest.raises(ValueError):
@@ -1540,8 +1526,8 @@ def test_currency_switch(self):
         )
         # A user displayed error should be generated, and its currency should be the same.
         self.assertStatus(200, resp)
-        self.assertIn('<p class="alert alert-danger">', resp.data.decode("utf-8"))
-        self.assertEqual(self.get_project("raclette").default_currency, "USD")
+        assert '<p class="alert alert-danger">' in resp.data.decode("utf-8")
+        assert self.get_project("raclette").default_currency == "USD"
 
     def test_currency_switch_to_bill_currency(self):
         # Default currency is 'XXX', but we should start from a project with a currency
@@ -1567,15 +1553,15 @@ def test_currency_switch_to_bill_currency(self):
         project = self.get_project("raclette")
 
         bill = project.get_bills().first()
-        self.assertEqual(
-            self.converter.exchange_currency(bill.amount, "EUR", "USD"),
-            bill.converted_amount,
+        assert (
+            self.converter.exchange_currency(bill.amount, "EUR", "USD")
+            == bill.converted_amount
         )
 
         # And switch project to the currency from the bill we created
         project.switch_currency("EUR")
         bill = project.get_bills().first()
-        self.assertEqual(bill.converted_amount, bill.amount)
+        assert bill.converted_amount == bill.amount
 
     def test_currency_switch_to_no_currency(self):
         # Default currency is 'XXX', but we should start from a project with a currency
@@ -1613,9 +1599,9 @@ def test_currency_switch_to_no_currency(self):
         project = self.get_project("raclette")
 
         for bill in project.get_bills_unordered():
-            self.assertEqual(
-                self.converter.exchange_currency(bill.amount, "EUR", "USD"),
-                bill.converted_amount,
+            assert (
+                self.converter.exchange_currency(bill.amount, "EUR", "USD")
+                == bill.converted_amount
             )
 
         # And switch project to no currency: amount should be equal to what was submitted
@@ -1623,7 +1609,7 @@ def test_currency_switch_to_no_currency(self):
         no_currency_bills = [
             (bill.amount, bill.converted_amount) for bill in project.get_bills()
         ]
-        self.assertEqual(no_currency_bills, [(5.0, 5.0), (10.0, 10.0)])
+        assert no_currency_bills == [(5.0, 5.0), (10.0, 10.0)]
 
     def test_amount_is_null(self):
         self.post_project("raclette")
@@ -1646,9 +1632,9 @@ def test_amount_is_null(self):
 
         # Bill should have been accepted
         project = self.get_project("raclette")
-        self.assertEqual(project.get_bills().count(), 1)
+        assert project.get_bills().count() == 1
         last_bill = project.get_bills().first()
-        self.assertEqual(last_bill.amount, 0)
+        assert last_bill.amount == 0
 
     def test_decimals_on_weighted_members_list(self):
         self.post_project("raclette")
@@ -1662,15 +1648,9 @@ def test_decimals_on_weighted_members_list(self):
 
         # check if weights of the users are 1, 1.1, 1.15 respectively
         resp = self.client.get("/raclette/")
-        self.assertIn(
-            'zorglub<span class="light">(x1)</span>', resp.data.decode("utf-8")
-        )
-        self.assertIn(
-            'tata<span class="light">(x1.1)</span>', resp.data.decode("utf-8")
-        )
-        self.assertIn(
-            'fred<span class="light">(x1.15)</span>', resp.data.decode("utf-8")
-        )
+        assert 'zorglub<span class="light">(x1)</span>' in resp.data.decode("utf-8")
+        assert 'tata<span class="light">(x1.1)</span>' in resp.data.decode("utf-8")
+        assert 'fred<span class="light">(x1.15)</span>' in resp.data.decode("utf-8")
 
     def test_amount_too_high(self):
         self.post_project("raclette")
@@ -1691,12 +1671,12 @@ def test_amount_too_high(self):
                 "original_currency": "EUR",
             },
         )
-        self.assertIn('<p class="alert alert-danger">', resp.data.decode("utf-8"))
+        assert '<p class="alert alert-danger">' in resp.data.decode("utf-8")
 
         # Without any check, the following request will fail.
         resp = self.client.get("/raclette/")
         # No bills, the previous one was not added
-        self.assertIn("No bills", resp.data.decode("utf-8"))
+        assert "No bills" in resp.data.decode("utf-8")
 
     def test_session_projects_migration_to_list(self):
         """In https://github.com/spiral-project/ihatemoney/pull/1082, session["projects"]
@@ -1707,16 +1687,16 @@ def test_session_projects_migration_to_list(self):
 
         with self.client as c:
             c.post("/authenticate", data={"id": "raclette", "password": "raclette"})
-            self.assertTrue(session["raclette"])
+            assert session["raclette"]
             # New behavior
-            self.assertIsInstance(session["projects"], dict)
+            assert isinstance(session["projects"], dict)
             # Now, go back to the past
             with c.session_transaction() as sess:
                 sess["projects"] = [("raclette", "raclette")]
             # It should convert entry to dict
             c.get("/")
-            self.assertIsInstance(session["projects"], dict)
-            self.assertIn("raclette", session["projects"])
+            assert isinstance(session["projects"], dict)
+            assert "raclette" in session["projects"]
 
     def test_rss_feed(self):
         """
@@ -2011,9 +1991,9 @@ def test_rss_feed_bad_token(self):
         token = project.generate_token("feed")
 
         resp = self.client.get(f"/raclette/feed/{token}.xml")
-        self.assertEqual(resp.status_code, 200)
+        assert resp.status_code == 200
         resp = self.client.get("/raclette/feed/invalid-token.xml")
-        self.assertEqual(resp.status_code, 404)
+        assert resp.status_code == 404
 
     def test_rss_feed_different_project_with_same_password(
         self,
@@ -2028,7 +2008,7 @@ def test_rss_feed_different_project_with_same_password(
         token = project.generate_token("feed")
 
         resp = self.client.get(f"/reblochon/feed/{token}.xml")
-        self.assertEqual(resp.status_code, 404)
+        assert resp.status_code == 404
 
     def test_rss_feed_different_project_with_different_password(
         self,
@@ -2043,7 +2023,7 @@ def test_rss_feed_different_project_with_different_password(
         token = project.generate_token("feed")
 
         resp = self.client.get(f"/reblochon/feed/{token}.xml")
-        self.assertEqual(resp.status_code, 404)
+        assert resp.status_code == 404
 
     def test_rss_feed_invalidated_token(self):
         """
@@ -2054,7 +2034,7 @@ def test_rss_feed_invalidated_token(self):
         token = project.generate_token("feed")
 
         resp = self.client.get(f"/raclette/feed/{token}.xml")
-        self.assertEqual(resp.status_code, 200)
+        assert resp.status_code == 200
 
         self.client.post(
             "/raclette/edit",
@@ -2069,8 +2049,4 @@ def test_rss_feed_invalidated_token(self):
         )
 
         resp = self.client.get(f"/raclette/feed/{token}.xml")
-        self.assertEqual(resp.status_code, 404)
-
-
-if __name__ == "__main__":
-    unittest.main()
+        assert resp.status_code == 404
diff --git a/ihatemoney/tests/common/ihatemoney_testcase.py b/ihatemoney/tests/common/ihatemoney_testcase.py
index 94b5161a4..6369a8aac 100644
--- a/ihatemoney/tests/common/ihatemoney_testcase.py
+++ b/ihatemoney/tests/common/ihatemoney_testcase.py
@@ -1,15 +1,13 @@
 import os
-from unittest.mock import MagicMock
 
-from flask_testing import TestCase
+import pytest
 
 from ihatemoney import models
-from ihatemoney.currency_convertor import CurrencyConverter
-from ihatemoney.run import create_app, db
 from ihatemoney.utils import generate_password_hash
 
 
-class BaseTestCase(TestCase):
+@pytest.mark.usefixtures("client", "converter")
+class BaseTestCase:
     SECRET_KEY = "TEST SESSION"
     SQLALCHEMY_DATABASE_URI = os.environ.get(
         "TESTING_SQLALCHEMY_DATABASE_URI", "sqlite://"
@@ -18,30 +16,6 @@ class BaseTestCase(TestCase):
     PASSWORD_HASH_METHOD = "pbkdf2:sha1:1"
     PASSWORD_HASH_SALT_LENGTH = 1
 
-    def create_app(self):
-        # Pass the test object as a configuration.
-        return create_app(self)
-
-    def setUp(self):
-        db.create_all()
-        # Add dummy data to CurrencyConverter for all tests (since it's a singleton)
-        mock_data = {
-            "USD": 1,
-            "EUR": 0.8,
-            "CAD": 1.2,
-            "PLN": 4,
-            CurrencyConverter.no_currency: 1,
-        }
-        converter = CurrencyConverter()
-        converter.get_rates = MagicMock(return_value=mock_data)
-        # Also add it to an attribute to make tests clearer
-        self.converter = converter
-
-    def tearDown(self):
-        # clean after testing
-        db.session.remove()
-        db.drop_all()
-
     def login(self, project, password=None, test_client=None):
         password = password or project
 
@@ -83,7 +57,7 @@ def import_project(self, id, data, success=True):
             data=data,
             # follow_redirects=True,
         )
-        self.assertEqual("/{id}/edit" in str(resp.response), not success)
+        assert ("/{id}/edit" in str(resp.response)) == (not success)
 
     def create_project(self, id, default_currency="XXX", name=None, password=None):
         name = name or str(id)
@@ -109,11 +83,9 @@ class IhatemoneyTestCase(BaseTestCase):
     def assertStatus(self, expected, resp, url=None):
         if url is None:
             url = resp.request.path
-        return self.assertEqual(
-            expected,
-            resp.status_code,
-            f"{url} expected {expected}, got {resp.status_code}",
-        )
+        assert (
+            expected == resp.status_code
+        ), f"{url} expected {expected}, got {resp.status_code}"
 
     def enable_admin(self, password="adminpass"):
         self.app.config["ACTIVATE_ADMIN_DASHBOARD"] = True
diff --git a/ihatemoney/tests/conftest.py b/ihatemoney/tests/conftest.py
new file mode 100644
index 000000000..08d6e8912
--- /dev/null
+++ b/ihatemoney/tests/conftest.py
@@ -0,0 +1,49 @@
+from unittest.mock import MagicMock
+
+from flask import Flask
+import pytest
+
+from ihatemoney.currency_convertor import CurrencyConverter
+from ihatemoney.run import create_app, db
+
+
+@pytest.fixture
+def app(request: pytest.FixtureRequest):
+    """Create the Flask app with database"""
+    app = create_app(request.cls)
+
+    with app.app_context():
+        db.create_all()
+    request.cls.app = app
+
+    yield app
+
+    # clean after testing
+    db.session.remove()
+    db.drop_all()
+
+
+@pytest.fixture
+def client(app: Flask, request: pytest.FixtureRequest):
+    client = app.test_client()
+    request.cls.client = client
+
+    yield client
+
+
+@pytest.fixture
+def converter(request: pytest.FixtureRequest):
+    # Add dummy data to CurrencyConverter for all tests (since it's a singleton)
+    mock_data = {
+        "USD": 1,
+        "EUR": 0.8,
+        "CAD": 1.2,
+        "PLN": 4,
+        CurrencyConverter.no_currency: 1,
+    }
+    converter = CurrencyConverter()
+    converter.get_rates = MagicMock(return_value=mock_data)
+    # Also add it to an attribute to make tests clearer
+    request.cls.converter = converter
+
+    yield converter
diff --git a/ihatemoney/tests/history_test.py b/ihatemoney/tests/history_test.py
index 9b4ec33fa..52ce28744 100644
--- a/ihatemoney/tests/history_test.py
+++ b/ihatemoney/tests/history_test.py
@@ -1,4 +1,6 @@
-import unittest
+import re
+
+import pytest
 
 from ihatemoney import history, models
 from ihatemoney.tests.common.help_functions import em_surround
@@ -6,18 +8,33 @@
 from ihatemoney.versioning import LoggingMode
 
 
-class HistoryTestCase(IhatemoneyTestCase):
-    def setUp(self):
-        super().setUp()
-        self.post_project("demo")
-        self.login("demo")
+@pytest.fixture
+def demo(client):
+    client.post(
+        "/create",
+        data={
+            "name": "demo",
+            "id": "demo",
+            "password": "demo",
+            "contact_email": "demo@notmyidea.org",
+            "default_currency": "XXX",
+            "project_history": True,
+        },
+    )
+    client.post(
+        "/authenticate",
+        data=dict(id="demo", password="demo"),
+    )
+
 
+@pytest.mark.usefixtures("demo")
+class TestHistory(IhatemoneyTestCase):
     def test_simple_create_logentry_no_ip(self):
         resp = self.client.get("/demo/history")
-        self.assertEqual(resp.status_code, 200)
-        self.assertIn(f"Project {em_surround('demo')} added", resp.data.decode("utf-8"))
-        self.assertEqual(resp.data.decode("utf-8").count("<td> -- </td>"), 1)
-        self.assertNotIn("127.0.0.1", resp.data.decode("utf-8"))
+        assert resp.status_code == 200
+        assert f"Project {em_surround('demo')} added" in resp.data.decode("utf-8")
+        assert resp.data.decode("utf-8").count("<td> -- </td>") == 1
+        assert "127.0.0.1" not in resp.data.decode("utf-8")
 
     def change_privacy_to(self, current_password, logging_preference):
         # Change only logging_preferences
@@ -36,42 +53,38 @@ def change_privacy_to(self, current_password, logging_preference):
 
         # Disable History
         resp = self.client.post("/demo/edit", data=new_data, follow_redirects=True)
-        self.assertEqual(resp.status_code, 200)
-        self.assertNotIn("alert-danger", resp.data.decode("utf-8"))
+        assert resp.status_code == 200
+        assert "alert-danger" not in resp.data.decode("utf-8")
 
         resp = self.client.get("/demo/edit")
-        self.assertEqual(resp.status_code, 200)
+        assert resp.status_code == 200
         if logging_preference == LoggingMode.DISABLED:
-            self.assertIn('<input id="project_history"', resp.data.decode("utf-8"))
+            assert '<input id="project_history"' in resp.data.decode("utf-8")
         else:
-            self.assertIn(
-                '<input checked id="project_history"', resp.data.decode("utf-8")
-            )
+            assert '<input checked id="project_history"' in resp.data.decode("utf-8")
 
         if logging_preference == LoggingMode.RECORD_IP:
-            self.assertIn('<input checked id="ip_recording"', resp.data.decode("utf-8"))
+            assert '<input checked id="ip_recording"' in resp.data.decode("utf-8")
         else:
-            self.assertIn('<input id="ip_recording"', resp.data.decode("utf-8"))
+            assert '<input id="ip_recording"' in resp.data.decode("utf-8")
 
     def assert_empty_history_logging_disabled(self):
         resp = self.client.get("/demo/history")
-        self.assertIn(
-            "This project has history disabled. New actions won't appear below.",
-            resp.data.decode("utf-8"),
-        )
-        self.assertIn("Nothing to list", resp.data.decode("utf-8"))
-        self.assertNotIn(
-            "The table below reflects actions recorded prior to disabling project history.",
-            resp.data.decode("utf-8"),
+        assert (
+            "This project has history disabled. New actions won't appear below."
+            in resp.data.decode("utf-8")
         )
-        self.assertNotIn(
-            "Some entries below contain IP addresses,", resp.data.decode("utf-8")
+        assert "Nothing to list" in resp.data.decode("utf-8")
+        assert (
+            "The table below reflects actions recorded prior to disabling project history."
+            not in resp.data.decode("utf-8")
         )
-        self.assertNotIn("127.0.0.1", resp.data.decode("utf-8"))
-        self.assertNotIn("<td> -- </td>", resp.data.decode("utf-8"))
-        self.assertNotIn(
-            f"Project {em_surround('demo')} added", resp.data.decode("utf-8")
+        assert "Some entries below contain IP addresses," not in resp.data.decode(
+            "utf-8"
         )
+        assert "127.0.0.1" not in resp.data.decode("utf-8")
+        assert "<td> -- </td>" not in resp.data.decode("utf-8")
+        assert f"Project {em_surround('demo')} added" not in resp.data.decode("utf-8")
 
     def test_project_edit(self):
         new_data = {
@@ -84,90 +97,86 @@ def test_project_edit(self):
         }
 
         resp = self.client.post("/demo/edit", data=new_data, follow_redirects=True)
-        self.assertEqual(resp.status_code, 200)
+        assert resp.status_code == 200
 
         resp = self.client.get("/demo/history")
-        self.assertEqual(resp.status_code, 200)
-        self.assertIn(f"Project {em_surround('demo')} added", resp.data.decode("utf-8"))
-        self.assertIn(
-            f"Project contact email changed to {em_surround('demo2@notmyidea.org')}",
-            resp.data.decode("utf-8"),
-        )
-        self.assertIn("Project private code changed", resp.data.decode("utf-8"))
-        self.assertIn(
-            f"Project renamed to {em_surround('demo2')}", resp.data.decode("utf-8")
-        )
-        self.assertLess(
-            resp.data.decode("utf-8").index("Project renamed "),
-            resp.data.decode("utf-8").index("Project contact email changed to "),
-        )
-        self.assertLess(
-            resp.data.decode("utf-8").index("Project renamed "),
-            resp.data.decode("utf-8").index("Project private code changed"),
-        )
-        self.assertEqual(resp.data.decode("utf-8").count("<td> -- </td>"), 5)
-        self.assertNotIn("127.0.0.1", resp.data.decode("utf-8"))
+        assert resp.status_code == 200
+        assert f"Project {em_surround('demo')} added" in resp.data.decode("utf-8")
+        assert (
+            f"Project contact email changed to {em_surround('demo2@notmyidea.org')}"
+            in resp.data.decode("utf-8")
+        )
+        assert "Project private code changed" in resp.data.decode("utf-8")
+        assert f"Project renamed to {em_surround('demo2')}" in resp.data.decode("utf-8")
+        assert resp.data.decode("utf-8").index("Project renamed ") < resp.data.decode(
+            "utf-8"
+        ).index("Project contact email changed to ")
+        assert resp.data.decode("utf-8").index("Project renamed ") < resp.data.decode(
+            "utf-8"
+        ).index("Project private code changed")
+        assert resp.data.decode("utf-8").count("<td> -- </td>") == 5
+        assert "127.0.0.1" not in resp.data.decode("utf-8")
 
     def test_project_privacy_edit(self):
         resp = self.client.get("/demo/edit")
-        self.assertEqual(resp.status_code, 200)
-        self.assertIn(
-            '<input checked id="project_history" name="project_history" type="checkbox" value="y">',
-            resp.data.decode("utf-8"),
+        assert resp.status_code == 200
+        assert (
+            '<input checked id="project_history" name="project_history" type="checkbox" value="y">'
+            in resp.data.decode("utf-8")
         )
 
         self.change_privacy_to("demo", LoggingMode.DISABLED)
 
         resp = self.client.get("/demo/history")
-        self.assertEqual(resp.status_code, 200)
-        self.assertIn("Disabled Project History\n", resp.data.decode("utf-8"))
-        self.assertEqual(resp.data.decode("utf-8").count("<td> -- </td>"), 2)
-        self.assertNotIn("127.0.0.1", resp.data.decode("utf-8"))
+        assert resp.status_code == 200
+        assert "Disabled Project History\n" in resp.data.decode("utf-8")
+        assert resp.data.decode("utf-8").count("<td> -- </td>") == 2
+        assert "127.0.0.1" not in resp.data.decode("utf-8")
 
         self.change_privacy_to("demo", LoggingMode.RECORD_IP)
 
         resp = self.client.get("/demo/history")
-        self.assertEqual(resp.status_code, 200)
-        self.assertIn(
-            "Enabled Project History & IP Address Recording", resp.data.decode("utf-8")
+        assert resp.status_code == 200
+        assert "Enabled Project History & IP Address Recording" in resp.data.decode(
+            "utf-8"
         )
-        self.assertEqual(resp.data.decode("utf-8").count("<td> -- </td>"), 2)
-        self.assertEqual(resp.data.decode("utf-8").count("127.0.0.1"), 1)
+        assert resp.data.decode("utf-8").count("<td> -- </td>") == 2
+        assert resp.data.decode("utf-8").count("127.0.0.1") == 1
 
         self.change_privacy_to("demo", LoggingMode.ENABLED)
 
         resp = self.client.get("/demo/history")
-        self.assertEqual(resp.status_code, 200)
-        self.assertIn("Disabled IP Address Recording\n", resp.data.decode("utf-8"))
-        self.assertEqual(resp.data.decode("utf-8").count("<td> -- </td>"), 2)
-        self.assertEqual(resp.data.decode("utf-8").count("127.0.0.1"), 2)
+        assert resp.status_code == 200
+        assert "Disabled IP Address Recording\n" in resp.data.decode("utf-8")
+        assert resp.data.decode("utf-8").count("<td> -- </td>") == 2
+        assert resp.data.decode("utf-8").count("127.0.0.1") == 2
 
     def test_project_privacy_edit2(self):
         self.change_privacy_to("demo", LoggingMode.RECORD_IP)
 
         resp = self.client.get("/demo/history")
-        self.assertEqual(resp.status_code, 200)
-        self.assertIn("Enabled IP Address Recording\n", resp.data.decode("utf-8"))
-        self.assertEqual(resp.data.decode("utf-8").count("<td> -- </td>"), 1)
-        self.assertEqual(resp.data.decode("utf-8").count("127.0.0.1"), 1)
+        assert resp.status_code == 200
+        assert "Enabled IP Address Recording\n" in resp.data.decode("utf-8")
+        assert resp.data.decode("utf-8").count("<td> -- </td>") == 1
+        assert resp.data.decode("utf-8").count("127.0.0.1") == 1
 
         self.change_privacy_to("demo", LoggingMode.DISABLED)
 
         resp = self.client.get("/demo/history")
-        self.assertEqual(resp.status_code, 200)
-        self.assertIn(
-            "Disabled Project History & IP Address Recording", resp.data.decode("utf-8")
+        assert resp.status_code == 200
+        assert "Disabled Project History & IP Address Recording" in resp.data.decode(
+            "utf-8"
         )
-        self.assertEqual(resp.data.decode("utf-8").count("<td> -- </td>"), 1)
-        self.assertEqual(resp.data.decode("utf-8").count("127.0.0.1"), 2)
+        assert resp.data.decode("utf-8").count("<td> -- </td>") == 1
+        assert resp.data.decode("utf-8").count("127.0.0.1") == 2
 
         self.change_privacy_to("demo", LoggingMode.ENABLED)
 
         resp = self.client.get("/demo/history")
-        self.assertEqual(resp.status_code, 200)
-        self.assertIn("Enabled Project History\n", resp.data.decode("utf-8"))
-        self.assertEqual(resp.data.decode("utf-8").count("<td> -- </td>"), 2)
-        self.assertEqual(resp.data.decode("utf-8").count("127.0.0.1"), 2)
+        assert resp.status_code == 200
+        assert "Enabled Project History\n" in resp.data.decode("utf-8")
+        assert resp.data.decode("utf-8").count("<td> -- </td>") == 2
+        assert resp.data.decode("utf-8").count("127.0.0.1") == 2
 
     def do_misc_database_operations(self, logging_mode):
         new_data = {
@@ -185,13 +194,13 @@ def do_misc_database_operations(self, logging_mode):
                 new_data["ip_recording"] = "y"
 
         resp = self.client.post("/demo/edit", data=new_data, follow_redirects=True)
-        self.assertEqual(resp.status_code, 200)
+        assert resp.status_code == 200
 
         # adds a member to this project
         resp = self.client.post(
             "/demo/members/add", data={"name": "zorglub"}, follow_redirects=True
         )
-        self.assertEqual(resp.status_code, 200)
+        assert resp.status_code == 200
 
         user_id = models.Person.query.one().id
 
@@ -207,7 +216,7 @@ def do_misc_database_operations(self, logging_mode):
             },
             follow_redirects=True,
         )
-        self.assertEqual(resp.status_code, 200)
+        assert resp.status_code == 200
 
         bill_id = models.Bill.query.one().id
 
@@ -223,16 +232,16 @@ def do_misc_database_operations(self, logging_mode):
             },
             follow_redirects=True,
         )
-        self.assertEqual(resp.status_code, 200)
+        assert resp.status_code == 200
         # delete the bill
         resp = self.client.post(f"/demo/delete/{bill_id}", follow_redirects=True)
-        self.assertEqual(resp.status_code, 200)
+        assert resp.status_code == 200
 
         # delete user using POST method
         resp = self.client.post(
             f"/demo/members/{user_id}/delete", follow_redirects=True
         )
-        self.assertEqual(resp.status_code, 200)
+        assert resp.status_code == 200
 
     def test_disable_clear_no_new_records(self):
         # Disable logging
@@ -240,27 +249,24 @@ def test_disable_clear_no_new_records(self):
 
         # Ensure we can't clear history with a GET or with a password-less POST
         resp = self.client.get("/demo/erase_history")
-        self.assertEqual(resp.status_code, 405)
+        assert resp.status_code == 405
         resp = self.client.post("/demo/erase_history", follow_redirects=True)
-        self.assertIn(
-            "Error deleting project history",
-            resp.data.decode("utf-8"),
-        )
+        assert "Error deleting project history" in resp.data.decode("utf-8")
 
         # List history
         resp = self.client.get("/demo/history")
-        self.assertEqual(resp.status_code, 200)
-        self.assertIn(
-            "This project has history disabled. New actions won't appear below.",
-            resp.data.decode("utf-8"),
+        assert resp.status_code == 200
+        assert (
+            "This project has history disabled. New actions won't appear below."
+            in resp.data.decode("utf-8")
         )
-        self.assertIn(
-            "The table below reflects actions recorded prior to disabling project history.",
-            resp.data.decode("utf-8"),
+        assert (
+            "The table below reflects actions recorded prior to disabling project history."
+            in resp.data.decode("utf-8")
         )
-        self.assertNotIn("Nothing to list", resp.data.decode("utf-8"))
-        self.assertNotIn(
-            "Some entries below contain IP addresses,", resp.data.decode("utf-8")
+        assert "Nothing to list" not in resp.data.decode("utf-8")
+        assert "Some entries below contain IP addresses," not in resp.data.decode(
+            "utf-8"
         )
 
         # Clear Existing Entries
@@ -269,7 +275,7 @@ def test_disable_clear_no_new_records(self):
             data={"password": "demo"},
             follow_redirects=True,
         )
-        self.assertEqual(resp.status_code, 200)
+        assert resp.status_code == 200
         self.assert_empty_history_logging_disabled()
 
         # Do lots of database operations & check that there's still no history
@@ -288,43 +294,38 @@ def test_clear_ip_records(self):
         self.change_privacy_to("123456", LoggingMode.ENABLED)
 
         resp = self.client.get("/demo/history")
-        self.assertEqual(resp.status_code, 200)
-        self.assertNotIn(
-            "This project has history disabled. New actions won't appear below.",
-            resp.data.decode("utf-8"),
+        assert resp.status_code == 200
+        assert (
+            "This project has history disabled. New actions won't appear below."
+            not in resp.data.decode("utf-8")
         )
-        self.assertNotIn(
-            "The table below reflects actions recorded prior to disabling project history.",
-            resp.data.decode("utf-8"),
+        assert (
+            "The table below reflects actions recorded prior to disabling project history."
+            not in resp.data.decode("utf-8")
         )
-        self.assertNotIn("Nothing to list", resp.data.decode("utf-8"))
-        self.assertIn(
-            "Some entries below contain IP addresses,", resp.data.decode("utf-8")
-        )
-        self.assertEqual(resp.data.decode("utf-8").count("127.0.0.1"), 12)
-        self.assertEqual(resp.data.decode("utf-8").count("<td> -- </td>"), 1)
+        assert "Nothing to list" not in resp.data.decode("utf-8")
+        assert "Some entries below contain IP addresses," in resp.data.decode("utf-8")
+        assert resp.data.decode("utf-8").count("127.0.0.1") == 12
+        assert resp.data.decode("utf-8").count("<td> -- </td>") == 1
 
         # Generate more operations to confirm additional IP info isn't recorded
         self.do_misc_database_operations(LoggingMode.ENABLED)
 
         resp = self.client.get("/demo/history")
-        self.assertEqual(resp.status_code, 200)
-        self.assertEqual(resp.data.decode("utf-8").count("127.0.0.1"), 12)
-        self.assertEqual(resp.data.decode("utf-8").count("<td> -- </td>"), 7)
+        assert resp.status_code == 200
+        assert resp.data.decode("utf-8").count("127.0.0.1") == 12
+        assert resp.data.decode("utf-8").count("<td> -- </td>") == 7
 
         # Ensure we can't clear IP data with a GET or with a password-less POST
         resp = self.client.get("/demo/strip_ip_addresses")
-        self.assertEqual(resp.status_code, 405)
+        assert resp.status_code == 405
         resp = self.client.post("/demo/strip_ip_addresses", follow_redirects=True)
-        self.assertIn(
-            "Error deleting recorded IP addresses",
-            resp.data.decode("utf-8"),
-        )
+        assert "Error deleting recorded IP addresses" in resp.data.decode("utf-8")
 
         resp = self.client.get("/demo/history")
-        self.assertEqual(resp.status_code, 200)
-        self.assertEqual(resp.data.decode("utf-8").count("127.0.0.1"), 12)
-        self.assertEqual(resp.data.decode("utf-8").count("<td> -- </td>"), 7)
+        assert resp.status_code == 200
+        assert resp.data.decode("utf-8").count("127.0.0.1") == 12
+        assert resp.data.decode("utf-8").count("<td> -- </td>") == 7
 
         # Clear IP Data
         resp = self.client.post(
@@ -333,33 +334,33 @@ def test_clear_ip_records(self):
             follow_redirects=True,
         )
 
-        self.assertEqual(resp.status_code, 200)
-        self.assertNotIn(
-            "This project has history disabled. New actions won't appear below.",
-            resp.data.decode("utf-8"),
+        assert resp.status_code == 200
+        assert (
+            "This project has history disabled. New actions won't appear below."
+            not in resp.data.decode("utf-8")
         )
-        self.assertNotIn(
-            "The table below reflects actions recorded prior to disabling project history.",
-            resp.data.decode("utf-8"),
+        assert (
+            "The table below reflects actions recorded prior to disabling project history."
+            not in resp.data.decode("utf-8")
         )
-        self.assertNotIn("Nothing to list", resp.data.decode("utf-8"))
-        self.assertNotIn(
-            "Some entries below contain IP addresses,", resp.data.decode("utf-8")
+        assert "Nothing to list" not in resp.data.decode("utf-8")
+        assert "Some entries below contain IP addresses," not in resp.data.decode(
+            "utf-8"
         )
-        self.assertEqual(resp.data.decode("utf-8").count("127.0.0.1"), 0)
-        self.assertEqual(resp.data.decode("utf-8").count("<td> -- </td>"), 19)
+        assert resp.data.decode("utf-8").count("127.0.0.1") == 0
+        assert resp.data.decode("utf-8").count("<td> -- </td>") == 19
 
     def test_logs_for_common_actions(self):
         # adds a member to this project
         resp = self.client.post(
             "/demo/members/add", data={"name": "zorglub"}, follow_redirects=True
         )
-        self.assertEqual(resp.status_code, 200)
+        assert resp.status_code == 200
 
         resp = self.client.get("/demo/history")
-        self.assertEqual(resp.status_code, 200)
-        self.assertIn(
-            f"Participant {em_surround('zorglub')} added", resp.data.decode("utf-8")
+        assert resp.status_code == 200
+        assert f"Participant {em_surround('zorglub')} added" in resp.data.decode(
+            "utf-8"
         )
 
         # create a bill
@@ -374,13 +375,12 @@ def test_logs_for_common_actions(self):
             },
             follow_redirects=True,
         )
-        self.assertEqual(resp.status_code, 200)
+        assert resp.status_code == 200
 
         resp = self.client.get("/demo/history")
-        self.assertEqual(resp.status_code, 200)
-        self.assertIn(
-            f"Bill {em_surround('fromage à raclette')} added",
-            resp.data.decode("utf-8"),
+        assert resp.status_code == 200
+        assert f"Bill {em_surround('fromage à raclette')} added" in resp.data.decode(
+            "utf-8"
         )
 
         # edit the bill
@@ -395,44 +395,37 @@ def test_logs_for_common_actions(self):
             },
             follow_redirects=True,
         )
-        self.assertEqual(resp.status_code, 200)
+        assert resp.status_code == 200
 
         resp = self.client.get("/demo/history")
-        self.assertEqual(resp.status_code, 200)
-        self.assertIn(
-            f"Bill {em_surround('fromage à raclette')} added",
-            resp.data.decode("utf-8"),
+        assert resp.status_code == 200
+        assert f"Bill {em_surround('fromage à raclette')} added" in resp.data.decode(
+            "utf-8"
         )
-        self.assertRegex(
-            resp.data.decode("utf-8"),
+        assert re.search(
             r"Bill %s:\s* Amount changed\s* from %s\s* to %s"
             % (
                 em_surround("fromage à raclette", regex_escape=True),
                 em_surround("25.0", regex_escape=True),
                 em_surround("10.0", regex_escape=True),
             ),
-        )
-        self.assertIn(
-            "Bill %s renamed to %s"
-            % (em_surround("fromage à raclette"), em_surround("new thing")),
             resp.data.decode("utf-8"),
         )
-        self.assertLess(
-            resp.data.decode("utf-8").index(
-                f"Bill {em_surround('fromage à raclette')} renamed to"
-            ),
-            resp.data.decode("utf-8").index("Amount changed"),
-        )
+        assert "Bill %s renamed to %s" % (
+            em_surround("fromage à raclette"),
+            em_surround("new thing"),
+        ) in resp.data.decode("utf-8")
+        assert resp.data.decode("utf-8").index(
+            f"Bill {em_surround('fromage à raclette')} renamed to"
+        ) < resp.data.decode("utf-8").index("Amount changed")
 
         # delete the bill
         resp = self.client.post("/demo/delete/1", follow_redirects=True)
-        self.assertEqual(resp.status_code, 200)
+        assert resp.status_code == 200
 
         resp = self.client.get("/demo/history")
-        self.assertEqual(resp.status_code, 200)
-        self.assertIn(
-            f"Bill {em_surround('new thing')} removed", resp.data.decode("utf-8")
-        )
+        assert resp.status_code == 200
+        assert f"Bill {em_surround('new thing')} removed" in resp.data.decode("utf-8")
 
         # edit user
         resp = self.client.post(
@@ -440,39 +433,35 @@ def test_logs_for_common_actions(self):
             data={"weight": 2, "name": "new name"},
             follow_redirects=True,
         )
-        self.assertEqual(resp.status_code, 200)
+        assert resp.status_code == 200
 
         resp = self.client.get("/demo/history")
-        self.assertEqual(resp.status_code, 200)
-        self.assertRegex(
-            resp.data.decode("utf-8"),
+        assert resp.status_code == 200
+        assert re.search(
             r"Participant %s:\s* weight changed\s* from %s\s* to %s"
             % (
                 em_surround("zorglub", regex_escape=True),
                 em_surround("1.0", regex_escape=True),
                 em_surround("2.0", regex_escape=True),
             ),
-        )
-        self.assertIn(
-            "Participant %s renamed to %s"
-            % (em_surround("zorglub"), em_surround("new name")),
             resp.data.decode("utf-8"),
         )
-        self.assertLess(
-            resp.data.decode("utf-8").index(
-                f"Participant {em_surround('zorglub')} renamed"
-            ),
-            resp.data.decode("utf-8").index("weight changed"),
-        )
+        assert "Participant %s renamed to %s" % (
+            em_surround("zorglub"),
+            em_surround("new name"),
+        ) in resp.data.decode("utf-8")
+        assert resp.data.decode("utf-8").index(
+            f"Participant {em_surround('zorglub')} renamed"
+        ) < resp.data.decode("utf-8").index("weight changed")
 
         # delete user using POST method
         resp = self.client.post("/demo/members/1/delete", follow_redirects=True)
-        self.assertEqual(resp.status_code, 200)
+        assert resp.status_code == 200
 
         resp = self.client.get("/demo/history")
-        self.assertEqual(resp.status_code, 200)
-        self.assertIn(
-            f"Participant {em_surround('new name')} removed", resp.data.decode("utf-8")
+        assert resp.status_code == 200
+        assert f"Participant {em_surround('new name')} removed" in resp.data.decode(
+            "utf-8"
         )
 
     def test_double_bill_double_person_edit_second(self):
@@ -504,9 +493,9 @@ def test_double_bill_double_person_edit_second(self):
 
         # Should be 5 history entries at this point
         resp = self.client.get("/demo/history")
-        self.assertEqual(resp.status_code, 200)
-        self.assertEqual(resp.data.decode("utf-8").count("<td> -- </td>"), 5)
-        self.assertNotIn("127.0.0.1", resp.data.decode("utf-8"))
+        assert resp.status_code == 200
+        assert resp.data.decode("utf-8").count("<td> -- </td>") == 5
+        assert "127.0.0.1" not in resp.data.decode("utf-8")
 
         # Edit ONLY the amount on the first bill
         self.client.post(
@@ -521,28 +510,27 @@ def test_double_bill_double_person_edit_second(self):
         )
 
         resp = self.client.get("/demo/history")
-        self.assertEqual(resp.status_code, 200)
-        self.assertRegex(
-            resp.data.decode("utf-8"),
+        assert resp.status_code == 200
+        assert re.search(
             r"Bill {}:\s* Amount changed\s* from {}\s* to {}".format(
                 em_surround("Bill 1", regex_escape=True),
                 em_surround("25.0", regex_escape=True),
                 em_surround("88.0", regex_escape=True),
             ),
+            resp.data.decode("utf-8"),
         )
 
-        self.assertNotRegex(
-            resp.data.decode("utf-8"),
+        assert not re.search(
             r"Removed\s* {}\s* and\s* {}\s* from\s* owers list".format(
                 em_surround("User 1", regex_escape=True),
                 em_surround("User 2", regex_escape=True),
             ),
             resp.data.decode("utf-8"),
-        )
+        ), resp.data.decode("utf-8")
 
         # Should be 6 history entries at this point
-        self.assertEqual(resp.data.decode("utf-8").count("<td> -- </td>"), 6)
-        self.assertNotIn("127.0.0.1", resp.data.decode("utf-8"))
+        assert resp.data.decode("utf-8").count("<td> -- </td>") == 6
+        assert "127.0.0.1" not in resp.data.decode("utf-8")
 
     def test_bill_add_remove_add(self):
         # add two members
@@ -565,13 +553,11 @@ def test_bill_add_remove_add(self):
         self.client.post("/demo/delete/1", follow_redirects=True)
 
         resp = self.client.get("/demo/history")
-        self.assertEqual(resp.status_code, 200)
-        self.assertEqual(resp.data.decode("utf-8").count("<td> -- </td>"), 5)
-        self.assertNotIn("127.0.0.1", resp.data.decode("utf-8"))
-        self.assertIn(f"Bill {em_surround('Bill 1')} added", resp.data.decode("utf-8"))
-        self.assertIn(
-            f"Bill {em_surround('Bill 1')} removed", resp.data.decode("utf-8")
-        )
+        assert resp.status_code == 200
+        assert resp.data.decode("utf-8").count("<td> -- </td>") == 5
+        assert "127.0.0.1" not in resp.data.decode("utf-8")
+        assert f"Bill {em_surround('Bill 1')} added" in resp.data.decode("utf-8")
+        assert f"Bill {em_surround('Bill 1')} removed" in resp.data.decode("utf-8")
 
         # Add a new bill
         self.client.post(
@@ -586,17 +572,15 @@ def test_bill_add_remove_add(self):
         )
 
         resp = self.client.get("/demo/history")
-        self.assertEqual(resp.status_code, 200)
-        self.assertEqual(resp.data.decode("utf-8").count("<td> -- </td>"), 6)
-        self.assertNotIn("127.0.0.1", resp.data.decode("utf-8"))
-        self.assertIn(f"Bill {em_surround('Bill 1')} added", resp.data.decode("utf-8"))
-        self.assertEqual(
-            resp.data.decode("utf-8").count(f"Bill {em_surround('Bill 1')} added"), 1
-        )
-        self.assertIn(f"Bill {em_surround('Bill 2')} added", resp.data.decode("utf-8"))
-        self.assertIn(
-            f"Bill {em_surround('Bill 1')} removed", resp.data.decode("utf-8")
+        assert resp.status_code == 200
+        assert resp.data.decode("utf-8").count("<td> -- </td>") == 6
+        assert "127.0.0.1" not in resp.data.decode("utf-8")
+        assert f"Bill {em_surround('Bill 1')} added" in resp.data.decode("utf-8")
+        assert (
+            resp.data.decode("utf-8").count(f"Bill {em_surround('Bill 1')} added") == 1
         )
+        assert f"Bill {em_surround('Bill 2')} added" in resp.data.decode("utf-8")
+        assert f"Bill {em_surround('Bill 1')} removed" in resp.data.decode("utf-8")
 
     def test_double_bill_double_person_edit_second_no_web(self):
         u1 = models.Person(project_id="demo", name="User 1")
@@ -617,7 +601,7 @@ def test_double_bill_double_person_edit_second_no_web(self):
         models.db.session.commit()
 
         history_list = history.get_history(self.get_project("demo"))
-        self.assertEqual(len(history_list), 5)
+        assert len(history_list) == 5
 
         # Change just the amount
         b1.amount = 5
@@ -626,8 +610,8 @@ def test_double_bill_double_person_edit_second_no_web(self):
         history_list = history.get_history(self.get_project("demo"))
         for entry in history_list:
             if "prop_changed" in entry:
-                self.assertNotIn("owers", entry["prop_changed"])
-        self.assertEqual(len(history_list), 6)
+                assert "owers" not in entry["prop_changed"]
+        assert len(history_list) == 6
 
     def test_delete_history_with_project(self):
         self.post_project("raclette", password="party")
@@ -659,8 +643,4 @@ def test_delete_history_with_project(self):
 
         # History should be equal to project creation
         history_list = history.get_history(self.get_project("raclette"))
-        self.assertEqual(len(history_list), 1)
-
-
-if __name__ == "__main__":
-    unittest.main()
+        assert len(history_list) == 1
diff --git a/ihatemoney/tests/import_test.py b/ihatemoney/tests/import_test.py
index bccb254d6..67709d8b3 100644
--- a/ihatemoney/tests/import_test.py
+++ b/ihatemoney/tests/import_test.py
@@ -1,12 +1,46 @@
 import copy
 import json
-import unittest
+
+import pytest
 
 from ihatemoney.tests.common.ihatemoney_testcase import IhatemoneyTestCase
 from ihatemoney.utils import list_of_dicts2csv, list_of_dicts2json
 
 
+@pytest.fixture
+def import_data(request: pytest.FixtureRequest):
+    data = [
+        {
+            "date": "2017-01-01",
+            "what": "refund",
+            "amount": 13.33,
+            "payer_name": "tata",
+            "payer_weight": 1.0,
+            "owers": ["fred"],
+        },
+        {
+            "date": "2016-12-31",
+            "what": "red wine",
+            "amount": 200.0,
+            "payer_name": "fred",
+            "payer_weight": 1.0,
+            "owers": ["zorglub", "tata"],
+        },
+        {
+            "date": "2016-12-31",
+            "what": "fromage a raclette",
+            "amount": 10.0,
+            "payer_name": "zorglub",
+            "payer_weight": 2.0,
+            "owers": ["zorglub", "fred", "tata", "pepe"],
+        },
+    ]
+    request.cls.data = data
+    yield data
+
+
 class CommonTestCase(object):
+    @pytest.mark.usefixtures("import_data")
     class Import(IhatemoneyTestCase):
         def setUp(self):
             super().setUp()
@@ -55,7 +89,7 @@ def test_import_currencies_in_empty_project_with_currency(self):
             bills = project.get_pretty_bills()
 
             # Check if all bills have been added
-            self.assertEqual(len(bills), len(self.data))
+            assert len(bills) == len(self.data)
 
             # Check if name of bills are ok
             b = [e["what"] for e in bills]
@@ -63,22 +97,22 @@ def test_import_currencies_in_empty_project_with_currency(self):
             ref = [e["what"] for e in self.data]
             ref.sort()
 
-            self.assertEqual(b, ref)
+            assert b == ref
 
             # Check if other informations in bill are ok
             for d in self.data:
                 for b in bills:
                     if b["what"] == d["what"]:
-                        self.assertEqual(b["payer_name"], d["payer_name"])
-                        self.assertEqual(b["amount"], d["amount"])
-                        self.assertEqual(b["currency"], d["currency"])
-                        self.assertEqual(b["payer_weight"], d["payer_weight"])
-                        self.assertEqual(b["date"], d["date"])
+                        assert b["payer_name"] == d["payer_name"]
+                        assert b["amount"] == d["amount"]
+                        assert b["currency"] == d["currency"]
+                        assert b["payer_weight"] == d["payer_weight"]
+                        assert b["date"] == d["date"]
                         list_project = [ower for ower in b["owers"]]
                         list_project.sort()
                         list_json = [ower for ower in d["owers"]]
                         list_json.sort()
-                        self.assertEqual(list_project, list_json)
+                        assert list_project == list_json
 
         def test_import_single_currency_in_empty_project_without_currency(self):
             # Import JSON with a single currency in an empty project with no
@@ -96,7 +130,7 @@ def test_import_single_currency_in_empty_project_without_currency(self):
             bills = project.get_pretty_bills()
 
             # Check if all bills have been added
-            self.assertEqual(len(bills), len(self.data))
+            assert len(bills) == len(self.data)
 
             # Check if name of bills are ok
             b = [e["what"] for e in bills]
@@ -104,23 +138,23 @@ def test_import_single_currency_in_empty_project_without_currency(self):
             ref = [e["what"] for e in self.data]
             ref.sort()
 
-            self.assertEqual(b, ref)
+            assert b == ref
 
             # Check if other informations in bill are ok
             for d in self.data:
                 for b in bills:
                     if b["what"] == d["what"]:
-                        self.assertEqual(b["payer_name"], d["payer_name"])
-                        self.assertEqual(b["amount"], d["amount"])
+                        assert b["payer_name"] == d["payer_name"]
+                        assert b["amount"] == d["amount"]
                         # Currency should have been stripped
-                        self.assertEqual(b["currency"], "XXX")
-                        self.assertEqual(b["payer_weight"], d["payer_weight"])
-                        self.assertEqual(b["date"], d["date"])
+                        assert b["currency"] == "XXX"
+                        assert b["payer_weight"] == d["payer_weight"]
+                        assert b["date"] == d["date"]
                         list_project = [ower for ower in b["owers"]]
                         list_project.sort()
                         list_json = [ower for ower in d["owers"]]
                         list_json.sort()
-                        self.assertEqual(list_project, list_json)
+                        assert list_project == list_json
 
         def test_import_multiple_currencies_in_empty_project_without_currency(self):
             # Import JSON with multiple currencies in an empty project with no
@@ -138,7 +172,7 @@ def test_import_multiple_currencies_in_empty_project_without_currency(self):
             bills = project.get_pretty_bills()
 
             # Check that there are no bills
-            self.assertEqual(len(bills), 0)
+            assert len(bills) == 0
 
         def test_import_no_currency_in_empty_project_with_currency(self):
             # Import JSON without currencies (from ihatemoney < 5) in an empty
@@ -154,7 +188,7 @@ def test_import_no_currency_in_empty_project_with_currency(self):
             bills = project.get_pretty_bills()
 
             # Check if all bills have been added
-            self.assertEqual(len(bills), len(self.data))
+            assert len(bills) == len(self.data)
 
             # Check if name of bills are ok
             b = [e["what"] for e in bills]
@@ -162,23 +196,23 @@ def test_import_no_currency_in_empty_project_with_currency(self):
             ref = [e["what"] for e in self.data]
             ref.sort()
 
-            self.assertEqual(b, ref)
+            assert b == ref
 
             # Check if other informations in bill are ok
             for d in self.data:
                 for b in bills:
                     if b["what"] == d["what"]:
-                        self.assertEqual(b["payer_name"], d["payer_name"])
-                        self.assertEqual(b["amount"], d["amount"])
+                        assert b["payer_name"] == d["payer_name"]
+                        assert b["amount"] == d["amount"]
                         # All bills are converted to default project currency
-                        self.assertEqual(b["currency"], "EUR")
-                        self.assertEqual(b["payer_weight"], d["payer_weight"])
-                        self.assertEqual(b["date"], d["date"])
+                        assert b["currency"] == "EUR"
+                        assert b["payer_weight"] == d["payer_weight"]
+                        assert b["date"] == d["date"]
                         list_project = [ower for ower in b["owers"]]
                         list_project.sort()
                         list_json = [ower for ower in d["owers"]]
                         list_json.sort()
-                        self.assertEqual(list_project, list_json)
+                        assert list_project == list_json
 
         def test_import_no_currency_in_empty_project_without_currency(self):
             # Import JSON without currencies (from ihatemoney < 5) in an empty
@@ -194,7 +228,7 @@ def test_import_no_currency_in_empty_project_without_currency(self):
             bills = project.get_pretty_bills()
 
             # Check if all bills have been added
-            self.assertEqual(len(bills), len(self.data))
+            assert len(bills) == len(self.data)
 
             # Check if name of bills are ok
             b = [e["what"] for e in bills]
@@ -202,22 +236,22 @@ def test_import_no_currency_in_empty_project_without_currency(self):
             ref = [e["what"] for e in self.data]
             ref.sort()
 
-            self.assertEqual(b, ref)
+            assert b == ref
 
             # Check if other informations in bill are ok
             for d in self.data:
                 for b in bills:
                     if b["what"] == d["what"]:
-                        self.assertEqual(b["payer_name"], d["payer_name"])
-                        self.assertEqual(b["amount"], d["amount"])
-                        self.assertEqual(b["currency"], "XXX")
-                        self.assertEqual(b["payer_weight"], d["payer_weight"])
-                        self.assertEqual(b["date"], d["date"])
+                        assert b["payer_name"] == d["payer_name"]
+                        assert b["amount"] == d["amount"]
+                        assert b["currency"] == "XXX"
+                        assert b["payer_weight"] == d["payer_weight"]
+                        assert b["date"] == d["date"]
                         list_project = [ower for ower in b["owers"]]
                         list_project.sort()
                         list_json = [ower for ower in d["owers"]]
                         list_json.sort()
-                        self.assertEqual(list_project, list_json)
+                        assert list_project == list_json
 
         def test_import_partial_project(self):
             # Import a JSON in a project with already existing data
@@ -250,7 +284,7 @@ def test_import_partial_project(self):
             bills = project.get_pretty_bills()
 
             # Check if all bills have been added
-            self.assertEqual(len(bills), len(self.data))
+            assert len(bills) == len(self.data)
 
             # Check if name of bills are ok
             b = [e["what"] for e in bills]
@@ -258,22 +292,22 @@ def test_import_partial_project(self):
             ref = [e["what"] for e in self.data]
             ref.sort()
 
-            self.assertEqual(b, ref)
+            assert b == ref
 
             # Check if other informations in bill are ok
             for d in self.data:
                 for b in bills:
                     if b["what"] == d["what"]:
-                        self.assertEqual(b["payer_name"], d["payer_name"])
-                        self.assertEqual(b["amount"], d["amount"])
-                        self.assertEqual(b["currency"], d["currency"])
-                        self.assertEqual(b["payer_weight"], d["payer_weight"])
-                        self.assertEqual(b["date"], d["date"])
+                        assert b["payer_name"] == d["payer_name"]
+                        assert b["amount"] == d["amount"]
+                        assert b["currency"] == d["currency"]
+                        assert b["payer_weight"] == d["payer_weight"]
+                        assert b["date"] == d["date"]
                         list_project = [ower for ower in b["owers"]]
                         list_project.sort()
                         list_json = [ower for ower in d["owers"]]
                         list_json.sort()
-                        self.assertEqual(list_project, list_json)
+                        assert list_project == list_json
 
         def test_import_wrong_data(self):
             self.post_project("raclette")
@@ -302,7 +336,7 @@ def test_import_wrong_data(self):
                 self.import_project("raclette", self.generate_form_data(data), 400)
 
 
-class ExportTestCase(IhatemoneyTestCase):
+class TestExport(IhatemoneyTestCase):
     def test_export(self):
         # Export a simple project without currencies
 
@@ -379,7 +413,7 @@ def test_export(self):
                 "owers": ["zorglub", "fred", "tata", "p\xe9p\xe9"],
             },
         ]
-        self.assertEqual(json.loads(resp.data.decode("utf-8")), expected)
+        assert json.loads(resp.data.decode("utf-8")) == expected
 
         # generate csv export of bills
         resp = self.client.get("/raclette/export/bills.csv")
@@ -392,9 +426,7 @@ def test_export(self):
         received_lines = resp.data.decode("utf-8").split("\n")
 
         for i, line in enumerate(expected):
-            self.assertEqual(
-                set(line.split(",")), set(received_lines[i].strip("\r").split(","))
-            )
+            assert set(line.split(",")) == set(received_lines[i].strip("\r").split(","))
 
         # generate json export of transactions
         resp = self.client.get("/raclette/export/transactions.json")
@@ -414,7 +446,7 @@ def test_export(self):
             },
         ]
 
-        self.assertEqual(json.loads(resp.data.decode("utf-8")), expected)
+        assert json.loads(resp.data.decode("utf-8")) == expected
 
         # generate csv export of transactions
         resp = self.client.get("/raclette/export/transactions.csv")
@@ -428,13 +460,11 @@ def test_export(self):
         received_lines = resp.data.decode("utf-8").split("\n")
 
         for i, line in enumerate(expected):
-            self.assertEqual(
-                set(line.split(",")), set(received_lines[i].strip("\r").split(","))
-            )
+            assert set(line.split(",")) == set(received_lines[i].strip("\r").split(","))
 
         # wrong export_format should return a 404
         resp = self.client.get("/raclette/export/transactions.wrong")
-        self.assertEqual(resp.status_code, 404)
+        assert resp.status_code == 404
 
     def test_export_with_currencies(self):
         self.post_project("raclette", default_currency="EUR")
@@ -513,7 +543,7 @@ def test_export_with_currencies(self):
                 "owers": ["zorglub", "fred", "tata", "p\xe9p\xe9"],
             },
         ]
-        self.assertEqual(json.loads(resp.data.decode("utf-8")), expected)
+        assert json.loads(resp.data.decode("utf-8")) == expected
 
         # generate csv export of bills
         resp = self.client.get("/raclette/export/bills.csv")
@@ -526,9 +556,7 @@ def test_export_with_currencies(self):
         received_lines = resp.data.decode("utf-8").split("\n")
 
         for i, line in enumerate(expected):
-            self.assertEqual(
-                set(line.split(",")), set(received_lines[i].strip("\r").split(","))
-            )
+            assert set(line.split(",")) == set(received_lines[i].strip("\r").split(","))
 
         # generate json export of transactions (in EUR!)
         resp = self.client.get("/raclette/export/transactions.json")
@@ -543,7 +571,7 @@ def test_export_with_currencies(self):
             {"amount": 38.45, "currency": "EUR", "receiver": "fred", "ower": "zorglub"},
         ]
 
-        self.assertEqual(json.loads(resp.data.decode("utf-8")), expected)
+        assert json.loads(resp.data.decode("utf-8")) == expected
 
         # generate csv export of transactions
         resp = self.client.get("/raclette/export/transactions.csv")
@@ -557,9 +585,7 @@ def test_export_with_currencies(self):
         received_lines = resp.data.decode("utf-8").split("\n")
 
         for i, line in enumerate(expected):
-            self.assertEqual(
-                set(line.split(",")), set(received_lines[i].strip("\r").split(","))
-            )
+            assert set(line.split(",")) == set(received_lines[i].strip("\r").split(","))
 
         # Change project currency to CAD
         project = self.get_project("raclette")
@@ -578,7 +604,7 @@ def test_export_with_currencies(self):
             {"amount": 57.67, "currency": "CAD", "receiver": "fred", "ower": "zorglub"},
         ]
 
-        self.assertEqual(json.loads(resp.data.decode("utf-8")), expected)
+        assert json.loads(resp.data.decode("utf-8")) == expected
 
         # generate csv export of transactions
         resp = self.client.get("/raclette/export/transactions.csv")
@@ -592,9 +618,7 @@ def test_export_with_currencies(self):
         received_lines = resp.data.decode("utf-8").split("\n")
 
         for i, line in enumerate(expected):
-            self.assertEqual(
-                set(line.split(",")), set(received_lines[i].strip("\r").split(","))
-            )
+            assert set(line.split(",")) == set(received_lines[i].strip("\r").split(","))
 
     def test_export_escape_formulae(self):
         self.post_project("raclette", default_currency="EUR")
@@ -624,23 +648,17 @@ def test_export_escape_formulae(self):
         received_lines = resp.data.decode("utf-8").split("\n")
 
         for i, line in enumerate(expected):
-            self.assertEqual(
-                set(line.split(",")), set(received_lines[i].strip("\r").split(","))
-            )
+            assert set(line.split(",")) == set(received_lines[i].strip("\r").split(","))
 
 
-class ImportTestCaseJSON(CommonTestCase.Import):
+class TestImportJSON(CommonTestCase.Import):
     def generate_form_data(self, data):
         return {"file": (list_of_dicts2json(data), "test.json")}
 
 
-class ImportTestCaseCSV(CommonTestCase.Import):
+class TestImportCSV(CommonTestCase.Import):
     def generate_form_data(self, data):
         formatted_data = copy.deepcopy(data)
         for d in formatted_data:
             d["owers"] = ", ".join([o for o in d.get("owers", [])])
         return {"file": (list_of_dicts2csv(formatted_data), "test.csv")}
-
-
-if __name__ == "__main__":
-    unittest.main()
diff --git a/ihatemoney/tests/main_test.py b/ihatemoney/tests/main_test.py
index b3cd5059b..70c119088 100644
--- a/ihatemoney/tests/main_test.py
+++ b/ihatemoney/tests/main_test.py
@@ -1,9 +1,9 @@
 import os
 import smtplib
 import socket
-import unittest
 from unittest.mock import MagicMock, patch
 
+import pytest
 from sqlalchemy import orm
 from werkzeug.security import check_password_hash
 
@@ -19,19 +19,18 @@
 __HERE__ = os.path.dirname(os.path.abspath(__file__))
 
 
-class ConfigurationTestCase(BaseTestCase):
+class TestConfiguration(BaseTestCase):
     def test_default_configuration(self):
         """Test that default settings are loaded when no other configuration file is specified"""
-        self.assertFalse(self.app.config["DEBUG"])
-        self.assertFalse(self.app.config["SQLALCHEMY_TRACK_MODIFICATIONS"])
-        self.assertEqual(
-            self.app.config["MAIL_DEFAULT_SENDER"],
-            ("Budget manager <admin@example.com>"),
+        assert not self.app.config["DEBUG"]
+        assert not self.app.config["SQLALCHEMY_TRACK_MODIFICATIONS"]
+        assert self.app.config["MAIL_DEFAULT_SENDER"] == (
+            "Budget manager <admin@example.com>"
         )
-        self.assertTrue(self.app.config["ACTIVATE_DEMO_PROJECT"])
-        self.assertTrue(self.app.config["ALLOW_PUBLIC_PROJECT_CREATION"])
-        self.assertFalse(self.app.config["ACTIVATE_ADMIN_DASHBOARD"])
-        self.assertFalse(self.app.config["ENABLE_CAPTCHA"])
+        assert self.app.config["ACTIVATE_DEMO_PROJECT"]
+        assert self.app.config["ALLOW_PUBLIC_PROJECT_CREATION"]
+        assert not self.app.config["ACTIVATE_ADMIN_DASHBOARD"]
+        assert not self.app.config["ENABLE_CAPTCHA"]
 
     def test_env_var_configuration_file(self):
         """Test that settings are loaded from a configuration file specified
@@ -40,7 +39,7 @@ def test_env_var_configuration_file(self):
             __HERE__, "ihatemoney_envvar.cfg"
         )
         load_configuration(self.app)
-        self.assertEqual(self.app.config["SECRET_KEY"], "lalatra")
+        assert self.app.config["SECRET_KEY"] == "lalatra"
 
         # Test that the specified configuration file is loaded
         # even if the default configuration file ihatemoney.cfg exists
@@ -50,7 +49,7 @@ def test_env_var_configuration_file(self):
         )
         self.app.config.root_path = __HERE__
         load_configuration(self.app)
-        self.assertEqual(self.app.config["SECRET_KEY"], "lalatra")
+        assert self.app.config["SECRET_KEY"] == "lalatra"
 
         os.environ.pop("IHATEMONEY_SETTINGS_FILE_PATH", None)
 
@@ -59,10 +58,10 @@ def test_default_configuration_file(self):
         in the current directory."""
         self.app.config.root_path = __HERE__
         load_configuration(self.app)
-        self.assertEqual(self.app.config["SECRET_KEY"], "supersecret")
+        assert self.app.config["SECRET_KEY"] == "supersecret"
 
 
-class ServerTestCase(IhatemoneyTestCase):
+class TestServer(IhatemoneyTestCase):
     def test_homepage(self):
         # See https://github.com/spiral-project/ihatemoney/pull/358
         self.app.config["APPLICATION_ROOT"] = "/"
@@ -80,7 +79,7 @@ def test_prefixed(self):
         self.assertStatus(200, req)
 
 
-class CommandTestCase(BaseTestCase):
+class TestCommand(BaseTestCase):
     def test_generate_config(self):
         """Simply checks that all config file generation
         - raise no exception
@@ -89,25 +88,25 @@ def test_generate_config(self):
         runner = self.app.test_cli_runner()
         for config_file in generate_config.params[0].type.choices:
             result = runner.invoke(generate_config, config_file)
-            self.assertNotEqual(len(result.output.strip()), 0)
+            assert len(result.output.strip()) != 0
 
     def test_generate_password_hash(self):
         runner = self.app.test_cli_runner()
         with patch("getpass.getpass", new=lambda prompt: "secret"):
             result = runner.invoke(password_hash)
-            self.assertTrue(check_password_hash(result.output.strip(), "secret"))
+            assert check_password_hash(result.output.strip(), "secret")
 
     def test_demo_project_deletion(self):
         self.create_project("demo")
-        self.assertEqual(self.get_project("demo").name, "demo")
+        assert self.get_project("demo").name == "demo"
 
         runner = self.app.test_cli_runner()
         runner.invoke(delete_project, "demo")
 
-        self.assertEqual(len(models.Project.query.all()), 0)
+        assert len(models.Project.query.all()) == 0
 
 
-class ModelsTestCase(IhatemoneyTestCase):
+class TestModels(IhatemoneyTestCase):
     def test_weighted_bills(self):
         """Test the SQL request that fetch all bills and weights"""
         self.post_project("raclette")
@@ -156,13 +155,13 @@ def test_weighted_bills(self):
         for weight, bill in project.get_bill_weights().all():
             if bill.what == "red wine":
                 pay_each_expected = 20 / 2
-                self.assertEqual(bill.amount / weight, pay_each_expected)
+                assert bill.amount / weight == pay_each_expected
             if bill.what == "fromage à raclette":
                 pay_each_expected = 10 / 4
-                self.assertEqual(bill.amount / weight, pay_each_expected)
+                assert bill.amount / weight == pay_each_expected
             if bill.what == "delicatessen":
                 pay_each_expected = 10 / 3
-                self.assertEqual(bill.amount / weight, pay_each_expected)
+                assert bill.amount / weight == pay_each_expected
 
     def test_bill_pay_each(self):
         self.post_project("raclette")
@@ -216,16 +215,16 @@ def test_bill_pay_each(self):
         for bill in zorglub_bills.all():
             if bill.what == "red wine":
                 pay_each_expected = 20 / 2
-                self.assertEqual(bill.pay_each(), pay_each_expected)
+                assert bill.pay_each() == pay_each_expected
             if bill.what == "fromage à raclette":
                 pay_each_expected = 10 / 4
-                self.assertEqual(bill.pay_each(), pay_each_expected)
+                assert bill.pay_each() == pay_each_expected
             if bill.what == "delicatessen":
                 pay_each_expected = 10 / 3
-                self.assertEqual(bill.pay_each(), pay_each_expected)
+                assert bill.pay_each() == pay_each_expected
 
 
-class EmailFailureTestCase(IhatemoneyTestCase):
+class TestEmailFailure(IhatemoneyTestCase):
     def test_creation_email_failure_smtp(self):
         self.login("raclette")
         with patch.object(
@@ -233,14 +232,14 @@ def test_creation_email_failure_smtp(self):
         ):
             resp = self.post_project("raclette")
         # Check that an error message is displayed
-        self.assertIn(
-            "We tried to send you an reminder email, but there was an error",
-            resp.data.decode("utf-8"),
+        assert (
+            "We tried to send you an reminder email, but there was an error"
+            in resp.data.decode("utf-8")
         )
         # Check that we were redirected to the home page anyway
-        self.assertIn(
-            '<a href="/raclette/members/add">Add the first participant',
-            resp.data.decode("utf-8"),
+        assert (
+            '<a href="/raclette/members/add">Add the first participant'
+            in resp.data.decode("utf-8")
         )
 
     def test_creation_email_failure_socket(self):
@@ -248,14 +247,14 @@ def test_creation_email_failure_socket(self):
         with patch.object(self.app.mail, "send", MagicMock(side_effect=socket.error)):
             resp = self.post_project("raclette")
         # Check that an error message is displayed
-        self.assertIn(
-            "We tried to send you an reminder email, but there was an error",
-            resp.data.decode("utf-8"),
+        assert (
+            "We tried to send you an reminder email, but there was an error"
+            in resp.data.decode("utf-8")
         )
         # Check that we were redirected to the home page anyway
-        self.assertIn(
-            '<a href="/raclette/members/add">Add the first participant',
-            resp.data.decode("utf-8"),
+        assert (
+            '<a href="/raclette/members/add">Add the first participant'
+            in resp.data.decode("utf-8")
         )
 
     def test_password_reset_email_failure(self):
@@ -266,14 +265,13 @@ def test_password_reset_email_failure(self):
                     "/password-reminder", data={"id": "raclette"}, follow_redirects=True
                 )
             # Check that an error message is displayed
-            self.assertIn(
-                "there was an error while sending you an email",
-                resp.data.decode("utf-8"),
+            assert "there was an error while sending you an email" in resp.data.decode(
+                "utf-8"
             )
             # Check that we were not redirected to the success page
-            self.assertNotIn(
-                "A link to reset your password has been sent to you",
-                resp.data.decode("utf-8"),
+            assert (
+                "A link to reset your password has been sent to you"
+                not in resp.data.decode("utf-8")
             )
 
     def test_invitation_email_failure(self):
@@ -287,17 +285,15 @@ def test_invitation_email_failure(self):
                     follow_redirects=True,
                 )
             # Check that an error message is displayed
-            self.assertIn(
-                "there was an error while trying to send the invitation emails",
-                resp.data.decode("utf-8"),
+            assert (
+                "there was an error while trying to send the invitation emails"
+                in resp.data.decode("utf-8")
             )
             # Check that we are still on the same page (no redirection)
-            self.assertIn(
-                "Invite people to join this project", resp.data.decode("utf-8")
-            )
+            assert "Invite people to join this project" in resp.data.decode("utf-8")
 
 
-class CaptchaTestCase(IhatemoneyTestCase):
+class TestCaptcha(IhatemoneyTestCase):
     ENABLE_CAPTCHA = True
 
     def test_project_creation_with_captcha_case_insensitive(self):
@@ -315,7 +311,7 @@ def test_project_creation_with_captcha_case_insensitive(self):
                     "captcha": "éùüß",
                 },
             )
-            self.assertEqual(len(models.Project.query.all()), 1)
+            assert len(models.Project.query.all()) == 1
 
     def test_project_creation_with_captcha(self):
         with self.client as c:
@@ -329,7 +325,7 @@ def test_project_creation_with_captcha(self):
                     "default_currency": "USD",
                 },
             )
-            self.assertEqual(len(models.Project.query.all()), 0)
+            assert len(models.Project.query.all()) == 0
 
             c.post(
                 "/create",
@@ -342,7 +338,7 @@ def test_project_creation_with_captcha(self):
                     "captcha": "nope",
                 },
             )
-            self.assertEqual(len(models.Project.query.all()), 0)
+            assert len(models.Project.query.all()) == 0
 
             c.post(
                 "/create",
@@ -355,7 +351,7 @@ def test_project_creation_with_captcha(self):
                     "captcha": "euro",
                 },
             )
-            self.assertEqual(len(models.Project.query.all()), 1)
+            assert len(models.Project.query.all()) == 1
 
     def test_api_project_creation_does_not_need_captcha(self):
         self.client.get("/")
@@ -368,11 +364,11 @@ def test_api_project_creation_does_not_need_captcha(self):
                 "contact_email": "raclette@notmyidea.org",
             },
         )
-        self.assertTrue(resp.status, 201)
-        self.assertEqual(len(models.Project.query.all()), 1)
+        assert resp.status_code == 201
+        assert len(models.Project.query.all()) == 1
 
 
-class TestCurrencyConverter(unittest.TestCase):
+class TestCurrencyConverter:
     converter = CurrencyConverter()
     mock_data = {
         "USD": 1,
@@ -386,28 +382,23 @@ class TestCurrencyConverter(unittest.TestCase):
     def test_only_one_instance(self):
         one = id(CurrencyConverter())
         two = id(CurrencyConverter())
-        self.assertEqual(one, two)
+        assert one == two
 
     def test_get_currencies(self):
-        self.assertCountEqual(
-            self.converter.get_currencies(),
-            ["USD", "EUR", "CAD", "PLN", CurrencyConverter.no_currency],
+        assert set(self.converter.get_currencies()) == set(
+            ["USD", "EUR", "CAD", "PLN", CurrencyConverter.no_currency]
         )
 
     def test_exchange_currency(self):
         result = self.converter.exchange_currency(100, "USD", "EUR")
-        self.assertEqual(result, 80.0)
+        assert result == 80.0
 
     def test_failing_remote(self):
         rates = {}
-        with patch("requests.Response.json", new=lambda _: {}), self.assertWarns(
+        with patch("requests.Response.json", new=lambda _: {}), pytest.warns(
             UserWarning
         ):
             # we need a non-patched converter, but it seems that MagickMock
             # is mocking EVERY instance of the class method. Too bad.
             rates = CurrencyConverter.get_rates(self.converter)
-        self.assertDictEqual(rates, {CurrencyConverter.no_currency: 1})
-
-
-if __name__ == "__main__":
-    unittest.main()
+        assert rates == {CurrencyConverter.no_currency: 1}