Write and Think Before You Code: How TDD Helped Me Develop Disaster-Resistant Access Control System
This post is about how I applied Test-Driven Development (TDD) while building new features for an exam invigilator management system.
Write and Think Before You Code: How TDD Helped Me Develop a Disaster-Resistant Access Control System

Image Source: https://www.kaizenko.com/what-is-test-driven-development-tdd/
There’s a certain kind of developer confidence that comes not from hoping your code works, but from knowing it does. I didn’t have that confidence at the start of this sprint. By the end of it, I did — and it wasn’t because I got lucky.
This post is about how I applied Test-Driven Development (TDD) while building new features for SiNgawas, an exam invigilator management system I’m developing with my team for my faculty at Universitas Indonesia. It’s about the frustration, the breakthroughs, and the one moment where a test caught a real security vulnerability before it ever touched our staging server.
What Is SiNgawas, and Why Does It Need Strict Testing?
SiNgawas is a web application that manages the scheduling and coordination of exam invigilators across our faculty. In the new development phase, we introduced several non-trivial features: event category management (KategoriEvent), dynamic session timing, schedule conflict detection on the frontend, and — the big one — a role-based export system restricted to a new Sekretariat role.
That last feature is what made rigorous testing non-negotiable. We’re talking about an endpoint that exports sensitive exam scheduling data. If the wrong person — or no one at all — accidentally gains access, that’s not just a bug. That’s a security incident.
So we went full TDD. Every feature. No exceptions.
The Theory: Red, Green, Refactor — and Why F.I.R.S.T Matters
If you haven’t encountered TDD before, the core idea is deceptively simple. You write a failing test first, then write just enough production code to make it pass, then clean up. This is the Red → Green → Refactor cycle, popularized by Kent Beck and extensively documented by Martin Fowler on his blog.
The reason this works isn’t magic. When you write the test first, you’re forced to think about the interface and behavior of your code before you write a single line of implementation. It’s design by specification.
But writing tests isn’t enough on its own — they need to be good tests. That’s where the F.I.R.S.T. principles come in, a set of properties that every unit test should satisfy:
- Fast — Tests should run in milliseconds, not seconds.
- Independent — No test should depend on another test’s state.
- Repeatable — Same result every time, in any environment.
- Self-validating — Tests either pass or fail. No manual inspection needed.
- Timely — Written before or alongside the production code, not after.
You can read a solid breakdown of these principles in this post by Leo Lanese on dev.to and also in the Django testing documentation, which covers how to structure tests in a Django project properly.
These weren’t abstract ideals for me — they became a practical checklist I ran through mentally before committing any test.
The Implementation: What We Actually Built
Looking at the commit history, the TDD cycle repeated itself cleanly across every feature. Here’s how it broke down.
Backend (Django): Model, Sessions, Roles, and Export
The first cycle was relatively gentle: adding the KategoriEvent model and its database seeding. Write the test, watch it fail, implement the model, watch it pass. Clean and satisfying.
Things got considerably more interesting with the dynamic session security tests. This wasn’t just about checking that a session returns a time — it was a full security audit written as tests, before any implementation existed. Here’s what that actually looked like:
class GetAllSesiSecurityTest(APITestCase):
def setUp(self):
self.url = reverse("kelola_jadwal:get_all_sesi")
self.tomorrow = date.today() + timedelta(days=1)
# ... (user, admin, and fixture setup omitted for brevity)
def test_unauthenticated_request(self):
unauthenticated_client = APIClient()
response = unauthenticated_client.get(self.url)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
def test_user_biasa_tidak_bisa_edit_waktu_ujian(self):
self.client.force_authenticate(user=self.user)
edit_url = reverse("kelola_jadwal:update_ujian", args=[str(self.event.id)])
response = self.client.put(edit_url, {
"waktu_mulai": "11:00",
"waktu_selesai": "11:50",
"tanggal_ujian": str(self.tomorrow),
"nama_ujian": "Ujian Manipulasi"
}, format="json")
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
def test_sql_injection_pada_field_waktu(self):
self.client.force_authenticate(user=self.admin)
edit_url = reverse("kelola_jadwal:update_ujian", args=[str(self.event.id)])
response = self.client.put(edit_url, {
"waktu_mulai": "09:00'; DROPTABLE kelola_jadwal_ujian; --",
"waktu_selesai": "09:30",
"tanggal_ujian": str(self.tomorrow),
"nama_ujian": "Ujian Injection"
}, format="json")
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
def test_waktu_mulai_lebih_besar_dari_waktu_selesai(self):
self.client.force_authenticate(user=self.admin)
edit_url = reverse("kelola_jadwal:update_ujian", args=[str(self.event.id)])
response = self.client.put(edit_url, {
"waktu_mulai": "09:30",
"waktu_selesai": "09:00",
"tanggal_ujian": str(self.tomorrow),
"nama_ujian": "Ujian Terbalik"
}, format="json")
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
def test_error_response_tidak_membocorkan_trace(self):
self.client.force_authenticate(user=self.user)
with patch(
"apps.kelola_jadwal.views.views_pengawas_tambahan.Sesi.objects.filter",
side_effect=Exception("internal db error detail")
):
response = self.client.get(self.url)
self.assertEqual(response.status_code, status.HTTP_500_INTERNAL_SERVER_ERROR)
response_text = str(response.data).lower()
self.assertNotIn("traceback", response_text)
self.assertNotIn("internal db error detail", response_text)
self.assertNotIn("line ", response_text)
Take a moment to read that last test — test_error_response_tidak_membocorkan_trace. It deliberately injects a database exception using unittest.mock.patch and then asserts that the error message, traceback, and internal details never leak into the API response. This is a category of security testing called information disclosure prevention, and I wouldn't have thought to test for it if I hadn't been forced into the "what could go wrong?" mindset that TDD demands.
The most complex backend cycle was the Sekretariat export feature, which spanned four commit pairs: role and permission class, then query repository and service orchestrator, then file writers, and finally the secured export endpoint with full API contract tests. Each layer was tested in isolation before the next was built — the service layer didn't care about HTTP, the endpoint tests didn't re-test business logic. Clean separation, exactly as F.I.R.S.T. demands.
Frontend (React/Js): Conflict Detection and its Modal Hook
On the frontend, the story was similar. We needed to detect scheduling conflicts when an invigilator tried to register for overlapping sessions. Before writing a single line of isJadwalOverlap, I wrote this test suite:
describe('isJadwalOverlap', () => {
it('should return true when jadwal fully overlaps', () => {
const jadwal1 = { mulai: '09:00', selesai: '11:00' };
const jadwal2 = { mulai: '09:00', selesai: '11:00' };
expect(isJadwalOverlap(jadwal1, jadwal2)).toBe(true);
});
it('should return true when jadwal partially overlaps (start inside)', () => {
const jadwal1 = { mulai: '09:00', selesai: '11:30' };
const jadwal2 = { mulai: '10:00', selesai: '12:00' };
expect(isJadwalOverlap(jadwal1, jadwal2)).toBe(true);
});
it('should return true when one jadwal is contained within another', () => {
const jadwal1 = { mulai: '09:00', selesai: '13:00' };
const jadwal2 = { mulai: '10:00', selesai: '11:00' };
expect(isJadwalOverlap(jadwal1, jadwal2)).toBe(true);
});
it('should return false when jadwal does not overlap', () => {
const jadwal1 = { mulai: '09:00', selesai: '11:00' };
const jadwal2 = { mulai: '13:00', selesai: '15:00' };
expect(isJadwalOverlap(jadwal1, jadwal2)).toBe(false);
});
// The tricky one — boundary condition
it('should return false when jadwal ends exactly when another starts', () => {
const jadwal1 = { mulai: '09:00', selesai: '11:00' };
const jadwal2 = { mulai: '11:00', selesai: '13:00' };
expect(isJadwalOverlap(jadwal1, jadwal2)).toBe(false);
});
it('should return false when jadwal is on different dates', () => {
const jadwal1 = { mulai: '09:00', selesai: '11:00', tanggal: '2026-04-08' };
const jadwal2 = { mulai: '09:00', selesai: '11:00', tanggal: '2026-04-09' };
expect(isJadwalOverlap(jadwal1, jadwal2)).toBe(false);
});
it('should return false when given the same day but different times with no overlap', () => {
const jadwal1 = { mulai: '07:00', selesai: '08:00', tanggal: '2026-04-08' };
const jadwal2 = { mulai: '08:00', selesai: '10:00', tanggal: '2026-04-08' };
expect(isJadwalOverlap(jadwal1, jadwal2)).toBe(false);
});
});
The one I want to call out specifically: should return false when jadwal ends exactly when another starts. This is a classic boundary condition — the kind of edge case that's genuinely easy to get wrong. If your overlap logic uses >= instead of >, this test catches it. Without writing the test first, I might have shipped that off-by-one error and only discovered it when two invigilators showed up for the same room at 11:00 wondering why the system let them both register.
The test suite also covers date awareness — two sessions at the same time on different days should not conflict. Again, obvious in hindsight, much easier to miss when you’re writing implementation-first.
After this, the TDD cycle continued: ConflictModal component tests → implementation → integration tests with handleDaftar → and finally a refactor into a clean useConflictModal custom hook. Every test stayed green through the refactor, which is the whole point.
The Challenges: Honest Frustrations and Real Breakthroughs
I’ll be honest — the first day of strict TDD felt slow. Like, painfully slow. My instinct as a developer is to build first and test later (or, let’s be real, sometimes never). Writing a test for code that doesn’t exist yet felt like writing a review for a movie that hasn’t been filmed.
The mocking part is where I genuinely got stuck. For the session security tests, I needed unittest.mock.patch to simulate a database failure mid-request — and then assert that the failure didn't surface internal details in the API response. Getting that patch target string exactly right (apps.kelola_jadwal.views.views_pengawas_tambahan.Sesi.objects.filter) took embarrassingly long. Patch the wrong path and your mock silently does nothing, the real code runs, and your test passes for entirely the wrong reason.
That’s a subtle but dangerous failure mode: a test that passes without actually testing what you think it’s testing. I had to verify my mock was working by temporarily making it side_effect=Exception("anything") and confirming the test did go red — before trusting that my GREEN state was meaningful.
The SQL injection test was another moment of genuine learning. I hadn’t originally planned to test for it. But once I was in the “what could go wrong?” headspace that TDD forces you into, it felt obvious: someone could try to pass a malformed time string. So I wrote the test — passing a deliberately malicious time value (a classic SQL injection payload) as waktu_mulai — and made it assert a 400 Bad Request. That test now lives in the codebase permanently. Future me, and future teammates, are protected by it.
The isJadwalOverlap boundary case on the frontend was its own small victory. The test should return false when jadwal ends exactly when another starts forced a very explicit decision: is 09:00–11:00 followed immediately by 11:00–13:00 a conflict or not? Semantically, no — they're back to back. But that question needed an answer in the test before it could have an answer in the code. That's the discipline TDD gives you.
The breakthrough moment came during the export endpoint. One of my API contract tests checked that an unauthenticated GET /export/ request returned a 403. When I first wired up the route, I accidentally attached it without the IsSekretariat permission class — a basic misconfiguration. The test turned red immediately. No staging deploy. No security report. Just a red terminal and a quick fix.
That felt genuinely good.
The Takeaway: Would I Do It Again?
Absolutely. Without hesitation.
Before this sprint, I thought TDD was something you did for textbook assignments or when you had unlimited time. Now I think the opposite: TDD is what you do precisely because you don’t have unlimited time to debug production issues or patch access control holes after the fact.
The Red-Green-Refactor cycle gave me a clear, repeatable workflow. The F.I.R.S.T. principles gave my tests quality standards to measure against. And the mocking practice — painful as it was to learn — gave me the ability to test components in true isolation, which is the only way to know what exactly broke when something breaks.
What surprised me most was how much the security quality of the codebase improved. Testing for SQL injection, unauthorized access, inverted time ranges, and error message leakage wasn’t something I planned up front. It emerged naturally from the TDD mindset of asking “how could this fail?” before writing any code. That’s a habit I’m keeping.
More than anything, the commit history tells the story better than I can. Every feature in SiNgawas Phase 2 has a test commit before an implementation commit. That’s not overhead — that’s evidence. Evidence that the feature was thought through before it was built, that edge cases were considered, and that the access control on sensitive endpoints was validated by a machine before any human reviewed it.
If you’re a fellow CS student or junior dev sitting on the fence about TDD: the slowness you feel at the start is real, but it’s temporary. The confidence you gain is permanent.
Built with Django (backend) and React (frontend) as part of the SiNgawas development project at Fasilkom UI. All commits referenced are from the project’s GitLab repository.
메타데이터
- post_id
- 133b97201cb2
- slug
- write-and-think-before-you-code-how-tdd-helped-me-develop-disaster-resistant-access-control-system-133b97201cb2
- url
- https://medium.com/@stefanustanjaya230105/write-and-think-before-you-code-how-tdd-helped-me-develop-disaster-resistant-access-control-system-133b97201cb2
- canonical_url
- https://medium.com/@stefanustanjaya230105/write-and-think-before-you-code-how-tdd-helped-me-develop-disaster-resistant-access-control-system-133b97201cb2
- author_url
- https://medium.com/@stefanustanjaya230105
- status
- ok
- fetched_at
- 2026-06-24 23:31:39