Why I Still Use unittest

BudgetFlow's tests use Python's unittest. When I started the project, I needed a test client, a fresh database, and a few helpers. TestCase covered those needs, so the suite stayed with it.

The project now has Flask and SQLAlchemy tests using the same setup. Most test classes inherit from BaseTestCase, which creates the app context and database tables and registers cleanup for each test.

Pytest would also work here. I have not found a reason to change the test framework while this setup remains easy to use.


The Shared Test Setup

unittest ships with Python, so the test runner adds no dependency. More useful to me is how plainly the setup reads in one base class.

Here is the relevant part of BaseTestCase:

class BaseTestCase(TestCase):
	app: Flask
	client: FlaskClient
	app_context: AppContext

	@classmethod
	def setUpClass(cls) -> None:
		super().setUpClass()
		cls.app = _create_app(ConfigTest)

	def setUp(self) -> None:
		self.client = self.app.test_client()
		self.app_context = self.app.app_context()
		self.app_context.push()
		self.addCleanup(self.app_context.pop)

		Base.metadata.create_all(db.engine)
		self.addCleanup(db.engine.dispose)
		self.addCleanup(shutdown_session, None)

		self.enterContext(
			patch("app.users.register.hash_and_salt_password", mock_hash_and_salt_password)
		)

The app is created once per test class. Each test gets its own client and context. addCleanup runs registered functions in reverse order even if a test fails. The full class also patches password hashing and login checks with enterContext.

Shared Helpers

The base class has a create_user() helper for tests that need an authenticated user:

def create_user(self, *, email="test@mail.com", name="Test User",
				password="test_key_123", login=True) -> User:
	pw_hash, pw_salt = mock_hash_and_salt_password(password)
	user = User(email=email, name=name, password_hash=pw_hash,
				password_salt=pw_salt, role="user",
				setting_inflation_rate_yearly=2.0,
				setting_simulation_years=10)
	db_session.add(user)
	db_session.commit()
	if login:
		self.login_user(user)
	return user

A test calls self.create_user() at the point it needs one. I can follow that method to see the default fields and whether it also logs the user in.

The same helper is available to every test class that inherits from BaseTestCase.

Assertions

The editor can suggest methods such as assertEqual, assertIn, and assertRaises as I type self.assert.

Pytest's plain assert has good failure output too. In this suite, self.assertEqual(x, y) gives me the comparison I need without changing the test runner.


What a Real Test Looks Like

A shortened version of a budget test shows how the setup is used:

class TestBudgets(BaseTestCase):
	def setUp(self):
		super().setUp()
		self.user = self.create_user()

	def _db_insert_budget(self, name="Test Budget"):
		budget = Budget(name=name, user_id=self.user.id,
						interval=FlowInterval.monthly)
		db_session.add(budget)
		db_session.commit()
		return budget

	def test_create_budgets(self):
		response = self.client.post(
			url_for("budgets.create"),
			data={"name": "Budget", "description": "Test 1"},
			follow_redirects=True,
		)
		self.assertEqual(response.status_code, HTTPStatus.OK)
		budgets = db_session.execute(
			select(Budget).filter_by(user_id=self.user.id)
		).scalars().all()
		self.assertEqual(len(budgets), 1)

The test creates its user in setUp(), makes a request, and checks the response and database. The project runs it with python -m unittest through the just test command.


Where Pytest Would Help

Pytest has features that can justify switching when a suite needs them.

Fixtures can share setup across tests and manage different lifetimes. BudgetFlow's current setup fits in a base class; a larger collection of test environments could change that.

Parameterization is convenient for running the same test with many inputs. unittest.subTest covers some of that use case, though the failure reporting and collection differ.

Plugins for parallel runs, async tests, and other workflows are another reason to use pytest. We have not needed them for this suite.

Pytest's assertion rewriting produces useful diagnostics. It is one of the features I would miss if this suite began to need more detailed failure messages.


Why the Standard Library Is Enough Here

Because unittest is part of Python, CI installs no separate test runner. The suite uses the same framework locally and in CI.

A new runner would be worth adding if it made these tests materially easier to write or maintain. So far, most tests need the same app and database setup, and the current base class provides it.

That is a project-specific choice, not advice to avoid pytest elsewhere.


When I Would Reconsider

I would reconsider if the suite began to need:

For now, the existing tests are direct enough that changing runners would add work without improving them.


The Current Choice

I can add a test by extending BaseTestCase, using its helper methods, and running just test. That workflow has stayed stable as BudgetFlow has grown.

If the suite changes enough to make that workflow awkward, I can evaluate pytest against the problem at hand. Until then, unittest does the job.

Start Your Financial Journey Today

Take control of your financial future with visual planning and dynamic budgeting. Sign up now and start managing your budget with confidence!