I want to be honest about where I was before these two weeks: I knew testing existed, I knew it mattered, and I was not doing it. Not because I did not care, more because it felt like something I would get to later, once I had the "real" skills down. Weeks 19 and 20 are where I stopped deferring it.
I am still midway through learning testing. This is not a wrap-up post. It is a checkpoint because some things clicked early enough that I want to document them before I move further in.
Testing is a prediction problem
To write a test, you have to predict a situation your code will face before your code exists. That sounds simple, but it cuts against how most beginners write code, myself included, up until recently. The usual pattern is to write the function, try it with one example, watch it work, and move on. Testing forces you out of that because a test case is not an example; it is a claim. You are claiming that given this input, this function will produce this output. Every time.
So you have to think in conditions. The happy path where everything goes right. The case where the email field is missing. The case where the email already exists in the database. The case where the ID is syntactically valid but points to nothing in storage. Each of those is a separate claim you are making about your code, and if you have not made the claim, you have not thought about the case, which means your server hits it and either crashes or returns something confusing.
That is not a testing insight. That is a thinking insight. Testing just forces it out of you.
How Jest works in practice
Jest gives you structure, assertions, and lifecycle hooks. describe groups related tests, test defines a single case, and expect is where you make the actual assertion. The matcher library is wide: strict equality with toBe, deep equality for objects and arrays with toEqual, checking that a function throws with toThrow, and async-aware matchers like resolves and rejects that let you assert on the outcome of a promise without an awkward try-catch wrapper.
The lifecycle hooks are beforeAll, afterAll, beforeEach, and afterEach. For database testing, these matter a lot. You connect once before all tests run, clear collections before each test so they do not contaminate each other, and drop everything when the suite finishes. Without that isolation, a test that creates a user in one case can break a later case that expects an empty collection.
Supertest and why app.js needs to be separate from server.js
Supertest wraps your Express app and lets you make real HTTP requests against it without starting an actual server. You call request(app).get('/api/users'), it fires the request through your middleware stack, and you get back a real response with a status code and body.
For that to work, your Express instance needs to be importable without triggering app.listen(). If your server startup lives in the same file as your app configuration, importing it for testing starts a real server, causes port conflicts, and generally does not work. The fix is to separate them: app.js configures and exports the Express instance, server.js imports it, and calls listen. This is a reasonable separation regardless of testing because your app is a module and your server is a process, and those are different things.
Mocking
Some functions you want to test depend on something external: a database query, a third-party API call, or a timer. You do not want your test to actually hit those things. You want to control what they return so you can test how your code responds.
jest.fn() creates a fake function you configure to return whatever value fits the test. jest.spyOn lets you intercept a method on a real object, override its implementation for the test, and restore the original afterward. I found mocking most useful for testing error paths. If you want to verify that your controller handles a database failure gracefully, you mock the database method to throw and check that the controller caught it and returned the right response. You do not need a real failure to test your error handling.
The in-memory database setup
For integration testing a MongoDB-backed API, I used mongodb-memory-server. It spins up a real MongoDB instance in memory for the test run, behaves like an actual database, enforces your schema validations, handles indexes including unique constraints, and resets cleanly when the tests finish. Your actual database never gets touched.
Where I actually am
I can write unit tests for utility functions and integration tests for CRUD routes. I know how to mock dependencies and how to set up and tear down a test database. There is more ahead, and I have not gotten there yet.
What has already changed is how I read code. I catch myself asking "what happens when this is null" or "what if this array is empty" before I finish writing the function. Whether that means fewer bugs in production, I cannot say yet. But it means fewer surprises when I go back to code I wrote a week ago, and that is something.
Top comments (0)