Writing code is only half the job. The other half is proving that it behaves the way you think it does — especially when everything goes wrong.
What if your code looks perfect, passes every test you wrote, gets a green CI check...
and is still wrong?
That question becomes even more interesting now that AI can generate functions, APIs, tests, mocks, documentation, and sometimes entire features in minutes.
Software development is becoming faster.
But faster code generation does not automatically mean safer software.
In fact, it might make good testing more important than ever.
Because this:
Code compiles ✅
Tests pass ✅
Coverage: 95% ✅
CI pipeline: Green ✅
does not necessarily mean this:
The software is correct ✅
A test suite can pass while missing the exact scenario that breaks your application in production.
So I decided to dive deeper into software testing — not just how to write a unit test, but how testing actually fits into software engineering.
And the biggest realization was simple:
Testing is not about proving that your software works.
It is about finding situations where your assumptions stop being true.
What Is Software Testing?
Software testing is the process of checking whether software behaves as expected and identifying situations where it does not.
Imagine we build this function:
function divide(a, b) {
return a / b;
}
We test:
divide(10, 2);
Expected result:
5
The test passes.
Great.
But what about:
divide(10, 0);
divide(null, 2);
divide("hello", 5);
divide(undefined, undefined);
Suddenly the problem becomes more interesting.
Testing is not just asking:
Does this work?
It is asking:
Under what conditions does this stop working?
That difference is huge.
Why Testing Matters
Imagine deploying an e-commerce application.
Everything appears fine during development.
Then production traffic arrives.
A user adds two items to the cart.
Another request updates inventory at the same time.
Payment succeeds.
Inventory fails.
The customer gets charged...
but no order is created.
Now we have:
- a frustrated customer
- inconsistent database state
- support tickets
- refund operations
- debugging time
- potentially lost trust
A small bug can become an expensive business problem.
That leads to an important principle:
The earlier you discover a defect, the easier and cheaper it generally is to fix.
Finding a problem while writing a function is much easier than discovering it after thousands of users have interacted with the system.
Testing vs Debugging
These terms are related, but they are not the same thing.
Testing finds failures.
Debugging investigates why those failures happen.
For example:
Test:
Checkout fails when quantity = 0.
Debugging:
Developer discovers the backend accepts negative inventory.
Testing answers:
Something is wrong.
Debugging answers:
Here is why it is wrong.
Verification vs Validation
Another distinction I used to mix up:
Verification asks:
Are we building the product correctly?
Validation asks:
Are we building the correct product?
Imagine the requirement says:
Passwords must contain at least 8 characters.
Your implementation correctly rejects seven-character passwords.
That is verification.
But what if the actual business requirement should have been:
Passwords must contain at least 12 characters.
Your code correctly implemented the wrong requirement.
That becomes a validation problem.
A technically perfect implementation can still solve the wrong problem.
Testing Cannot Prove That Bugs Don't Exist
This is probably one of the most important principles in testing.
Suppose your application has 20,000 tests.
Every test passes.
Can you say:
This software contains zero bugs.
No.
Tests can demonstrate the presence of defects.
They cannot prove their complete absence.
Why?
Because exhaustive testing is generally impossible.
Consider a simple text input.
Possible variables include:
Length
Characters
Encoding
Language
Whitespace
Special characters
Emoji
Null values
Extremely large values
Malicious input
Multiply that by:
Browsers
Operating systems
Network conditions
Permissions
Database states
User states
Concurrent requests
Third-party services
The number of possible combinations becomes enormous.
Testing therefore becomes a problem of intelligent risk selection.
Think Like a Breaker
Developers naturally think:
How can I make this work?
Testing introduces another mindset:
How can I make this fail?
Suppose we're testing login.
Most developers start here:
Valid email
+
Valid password
=
Login succeeds
A tester starts asking:
What if the password is wrong?
What if the account is locked?
What if the email doesn't exist?
What if the password is empty?
What if the database is unavailable?
What if the authentication service times out?
What if 50,000 users log in simultaneously?
What if the access token is expired?
What if someone modifies the token?
What if one user tries accessing another user's account?
Now we're testing software.
The Different Levels of Testing
Testing is not one giant activity.
Different tests protect different layers of your system.
The major levels include:
Unit Testing
Integration Testing
System Testing
Acceptance Testing
And in real projects you will also encounter:
Smoke Testing
Sanity Testing
Regression Testing
Component Testing
API Testing
Contract Testing
End-to-End Testing
Alpha Testing
Beta Testing
User Acceptance Testing
Let's break down the most important ones.
1. Unit Testing
Unit tests validate small pieces of software independently.
A unit might be:
Function
Method
Class
Small module
Example:
function calculateDiscount(price, percentage) {
return price - price * (percentage / 100);
}
A unit test could look like:
test("applies a 20% discount", () => {
expect(calculateDiscount(100, 20)).toBe(80);
});
Simple.
Fast.
Easy to understand.
But we shouldn't stop there.
test("handles zero discount", () => {
expect(calculateDiscount(100, 0)).toBe(100);
});
test("handles zero price", () => {
expect(calculateDiscount(0, 20)).toBe(0);
});
Depending on our requirements, we might also test:
Negative price
Negative percentage
Discount > 100%
Non-numeric values
Null
Undefined
Good unit tests should generally be:
Fast
Independent
Deterministic
Readable
Self-validating
Focused
Arrange → Act → Assert
One of the easiest ways to structure tests is AAA.
Arrange
Prepare everything needed.
const price = 100;
const discount = 20;
Act
Execute the behavior.
const result = calculateDiscount(price, discount);
Assert
Verify the result.
expect(result).toBe(80);
Complete test:
test("applies discount correctly", () => {
// Arrange
const price = 100;
const discount = 20;
// Act
const result = calculateDiscount(price, discount);
// Assert
expect(result).toBe(80);
});
Another popular format is:
Given
When
Then
We'll come back to that when discussing BDD.
2. Integration Testing
Unit tests might prove that components work individually.
But production systems aren't made of isolated components.
They communicate.
For example:
Controller
↓
Service
↓
Repository
↓
Database
Every piece could work independently.
Yet this connection could still be broken:
Service → Database
Maybe:
- the SQL query is incorrect
- schema names changed
- serialization is wrong
- transaction logic fails
- credentials are incorrect
- connection pooling behaves differently
Integration testing verifies that components work together correctly.
Example:
test("creates a user in the database", async () => {
const response = await request(app)
.post("/users")
.send({
name: "Dhruv",
email: "dhruv@example.com"
});
expect(response.status).toBe(201);
const user = await database.users.findByEmail(
"dhruv@example.com"
);
expect(user).toBeDefined();
});
Now we're testing more than one function.
We're checking the interaction between:
HTTP layer
Application logic
Database
3. API Testing
API testing deserves special attention because APIs often sit at the boundaries between systems.
Imagine:
POST /api/orders
A weak test checks:
Status = 201
A stronger test checks:
Correct status?
Correct response body?
Correct schema?
Correct headers?
Order stored?
Authentication enforced?
Authorization enforced?
Invalid input rejected?
Duplicate request handled?
Rate limit enforced?
Errors formatted correctly?
For example:
const response = await request(app)
.post("/api/orders")
.set("Authorization", `Bearer ${token}`)
.send({
productId: "123",
quantity: 2
});
expect(response.status).toBe(201);
expect(response.body).toHaveProperty("orderId");
expect(response.body.quantity).toBe(2);
Then negative tests:
No token → 401
Wrong permission → 403
Product missing → 404
Quantity = 0 → 400
Malformed body → 400
Then boundaries:
quantity = 1
quantity = maximum allowed
quantity > maximum allowed
Then behavior:
Product out of stock
Payment rejected
Database unavailable
Duplicate order request
The test surface grows quickly.
4. End-to-End Testing
End-to-end testing checks complete workflows from the user's perspective.
Imagine an online store.
A critical journey might be:
User opens site
↓
Searches product
↓
Opens product
↓
Adds product to cart
↓
Logs in
↓
Checks out
↓
Makes payment
↓
Receives confirmation
An E2E test might use Playwright:
test("user can complete checkout", async ({ page }) => {
await page.goto("https://example.com");
await page.getByPlaceholder("Search").fill("Keyboard");
await page.getByText("Mechanical Keyboard").click();
await page.getByRole("button", {
name: "Add to cart"
}).click();
await page.getByRole("link", {
name: "Cart"
}).click();
await expect(
page.getByText("Mechanical Keyboard")
).toBeVisible();
});
Tools commonly used include:
Playwright
Cypress
Selenium
Puppeteer
E2E testing gives strong confidence.
But it comes with costs.
E2E tests tend to be:
Slower
More expensive
More brittle
Harder to debug
More dependent on environment
That's why writing 10,000 E2E tests usually isn't the answer.
The Testing Pyramid
This gives us one of the most popular testing models:
/\
/ \
/ E2E\
/------\
/ \
/Integration\
/------------\
/ \
/ Unit Tests \
/__________________\
The basic philosophy:
Many unit tests
Some integration tests
Few E2E tests
Why?
Because unit tests are usually cheap and fast.
Integration tests provide deeper confidence but require more resources.
E2E tests exercise realistic behavior but are expensive.
A healthy strategy tries to get maximum confidence without making every commit take 45 minutes.
Smoke Testing vs Sanity Testing
These two are easy to confuse.
Smoke testing asks:
Is the important functionality alive at all?
After deployment:
Application loads ✅
Login works ✅
Database reachable ✅
Critical API works ✅
If smoke testing fails, there is little reason to continue deeper testing.
Sanity testing is narrower.
It asks whether a particular change appears to work.
For example:
Developer fixes password reset.
Sanity test:
Does password reset now work?
Think:
Smoke → Broad and shallow
Sanity → Narrow and focused
Regression Testing
You fix Bug #427:
Users with apostrophes in their names cannot register.
You add a test.
test("allows apostrophes in user names", () => {
const result = validateName("O'Connor");
expect(result).toBe(true);
});
Six months later, another developer changes the validator.
If the bug returns, the test catches it.
That is the heart of regression testing.
Regression testing asks:
Did the new change break something that previously worked?
And there is a powerful engineering habit hidden here:
Every important production bug should ideally leave behind a regression test.
The application shouldn't just get fixed.
The test suite should get smarter.
Test Doubles: Dummy vs Stub vs Spy vs Mock vs Fake
This area confused me at first because people often use "mock" for everything.
But these concepts have slightly different purposes.
Dummy
An object passed simply because something requires it.
It isn't actually used.
const dummyLogger = {};
Stub
Provides predefined responses.
const userRepository = {
findById: () => ({
id: 1,
name: "Dhruv"
})
};
Spy
Records interactions.
You might ask:
Was this function called?
How many times?
With which arguments?
Mock
Usually includes expectations about interactions.
expect(sendEmail).toHaveBeenCalledWith(
"user@example.com"
);
Fake
A working but simplified implementation.
For example:
Production → PostgreSQL
Testing → In-memory database
Test doubles are useful.
But they introduce one of the most dangerous testing traps.
Over-Mocking
Imagine your real payment provider responds:
{
"payment_id": "abc123",
"status": "approved"
}
But your mock returns:
{
"id": "abc123",
"success": true
}
Your tests pass.
Your production integration fails.
Beautiful.
You successfully tested a service that does not exist.
This is why:
The more mocks you use, the more careful you must be that your simulated world still resembles reality.
Mocks are tools.
Not proof.
Test-Driven Development
TDD follows a famous cycle:
RED
↓
GREEN
↓
REFACTOR
↺
Red
Write a failing test.
test("adds two numbers", () => {
expect(add(2, 3)).toBe(5);
});
There is no implementation yet.
Test fails.
Green
Write the smallest implementation needed.
function add(a, b) {
return a + b;
}
Test passes.
Refactor
Improve the implementation while keeping the test green.
TDD isn't necessarily about having maximum tests.
Its deeper benefit is forcing you to think about behavior before implementation.
Behavior-Driven Development
BDD shifts the language toward behavior.
Instead of thinking:
What function should I test?
we describe expected system behavior.
Example:
Feature: Shopping Cart
Scenario: Add product to cart
Given the user has an empty cart
When the user adds a keyboard
Then the cart should contain 1 keyboard
The structure:
Given → Context
When → Action
Then → Expected outcome
This can help bridge communication between:
Developers
QA engineers
Product owners
Business stakeholders
Tools include:
Cucumber
Behave
SpecFlow
One of BDD's interesting ideas is that well-written scenarios can become living documentation.
Positive Testing vs Negative Testing
Positive testing asks:
Does the system work with valid input?
Example:
Valid email
Valid password
→ Login succeeds
Negative testing asks:
Does the system behave correctly with invalid or unexpected input?
Wrong password
→ Login rejected
But negative testing goes much further:
Empty password
Extremely long password
Malformed request
Invalid token
Expired token
Missing database field
Duplicate request
Unexpected content type
This is where many interesting bugs live.
The happy path tells you the feature works.
Negative testing tells you whether the feature survives reality.
Boundary Value Testing
Suppose an API accepts age:
18 ≤ age ≤ 100
Testing this:
age = 50
is useful.
But these values are often more interesting:
17 ❌
18 ✅
19 ✅
99 ✅
100 ✅
101 ❌
Why?
Because defects frequently appear around boundaries.
The same concept applies to:
Array size
Character limits
Pagination
Rate limits
Upload sizes
Price ranges
Dates
Memory limits
Performance Testing
Your API can be logically correct and still be unusable.
Suppose:
GET /products
returns the correct products.
But:
Response time = 12 seconds
Functionally correct?
Yes.
Acceptable?
Probably not.
This is why performance is another dimension of testing.
Load Testing
Load testing checks how the system behaves under expected traffic.
Example:
1,000 concurrent users
5,000 requests/minute
Questions:
What's the response time?
What's the throughput?
How many requests fail?
How much CPU is used?
How much memory?
Stress Testing
Stress testing goes beyond normal capacity.
Maybe expected load is:
5,000 users
We push:
10,000
20,000
50,000
We're asking:
Where does the system break?
And equally important:
How does it break?
Does it degrade gracefully?
Or completely collapse?
Spike Testing
What if traffic changes like this?
1,000 users
↓
1,200
↓
1,500
↓
50,000
↓
2,000
This can happen from:
Product launches
Breaking news
Ticket sales
Viral content
Flash sales
Spike testing checks whether infrastructure can survive sudden bursts.
Soak Testing
Some bugs don't appear immediately.
A service might run beautifully for 10 minutes.
After eight hours:
Memory climbs continuously.
Connections don't close.
Threads accumulate.
Disk usage increases.
Soak testing runs the system for an extended period to discover issues such as:
Memory leaks
Resource leaks
Connection leaks
Slow degradation
Performance Metrics That Actually Matter
Instead of only looking at averages, modern systems often monitor percentiles.
Imagine response times:
p50 = 100 ms
p95 = 250 ms
p99 = 2,800 ms
The average might look fine.
But 1% of users are experiencing nearly three seconds of latency.
At millions of requests, that's not a small group.
Security Testing
Now imagine your application is:
Fast ✅
Reliable ✅
Scalable ✅
Well tested ✅
Easy to use ✅
but this works:
' OR '1'='1
Not good.
Security testing asks different questions.
Instead of:
Will users be able to use this?
we also ask:
How could an attacker abuse this?
Testing areas include:
SQL Injection
XSS
CSRF
Authentication
Authorization
Session management
Input validation
Sensitive data exposure
Security headers
HTTPS/TLS
Dependency vulnerabilities
Authentication Is Not Authorization
This distinction is especially important.
Authentication:
Who are you?
Authorization:
What are you allowed to do?
Imagine:
GET /api/users/100
The user is authenticated.
But what happens if they change the URL?
GET /api/users/101
If they can suddenly see another user's private data, authentication worked.
Authorization failed.
A good test suite should check both.
Test Coverage Is Not Test Quality
This is another area where metrics can create false confidence.
Suppose we have:
function isAdult(age) {
return age >= 18;
}
Test:
test("25 is an adult", () => {
expect(isAdult(25)).toBe(true);
});
The function may have:
100% line coverage
But we never tested:
17
18
Negative values
Null
Strings
Coverage tells us:
Which code executed?
It does not automatically tell us:
Did we verify the correct behavior?
Common coverage types include:
Line coverage
Statement coverage
Branch coverage
Function coverage
Condition coverage
Path coverage
Branch coverage can be more useful than simple line coverage.
Example:
if (user.isAdmin) {
showAdminPanel();
} else {
showDashboard();
}
A test that only covers:
isAdmin = true
may execute most of the code while completely ignoring the other behavior.
Mutation Testing: An Interesting Question
Here's a cool idea.
Instead of asking:
How much code did my tests execute?
Mutation testing asks:
Would my tests notice if my code were wrong?
Suppose your code says:
if (age >= 18)
A mutation testing tool might temporarily change it to:
if (age > 18)
Then run your tests.
If every test still passes...
your tests probably missed the boundary.
That's a much more interesting signal than line coverage alone.
Common mutation testing tools include:
PIT
Stryker
These tools help evaluate whether your tests can actually detect small changes in behavior.
Test Data Matters More Than It Looks
Tests are only as useful as the situations they represent.
Imagine testing a username field using:
Dhruv
Good start.
But what about:
D
Dhruv Patel
O'Connor
José
李明
😀
""
10,000 characters
Real users produce messy data.
Test data strategies include:
Hardcoded data
Factories
Builders
Fixtures
Seed data
Random data
Generated data
Anonymized production data
But random data creates another problem:
How do you reproduce the failure?
This is why deterministic testing often matters.
If random values are used, keeping a reproducible seed can help.
Flaky Tests Are Dangerous
Consider:
Run 1 → Pass
Run 2 → Pass
Run 3 → Fail
Run 4 → Pass
Run 5 → Fail
No code changed.
That is a flaky test.
Potential causes include:
Timing
Race conditions
Network dependency
Random data
Shared test state
Async behavior
Incorrect waits
External APIs
Environment differences
Flaky tests are especially harmful because they destroy trust.
Eventually a developer sees:
CI FAILED
and thinks:
Probably just the flaky test again.
Then one day the failure is real.
And everyone ignores it.
A test suite only protects the system if engineers trust it.
Tests Should Be Independent
Bad:
Test A creates user
↓
Test B expects that user
↓
Test C deletes that user
If Test B runs first:
FAIL
If Test C runs before B:
FAIL
Tests shouldn't depend on execution order.
Each test should ideally create the state it needs and clean up afterward.
Test Behavior, Not Implementation Details
Suppose we have:
function getFullName(user) {
return `${user.firstName} ${user.lastName}`;
}
A good test asks:
Does getFullName return "Dhruv Patel"?
A fragile test might assert internal calls that aren't actually part of the requirement.
Why is that bad?
Because now harmless refactoring breaks tests.
Tests should ideally survive implementation changes as long as externally expected behavior remains correct.
That leads to an excellent principle:
Tests should make refactoring safer, not punish you for refactoring.
Avoid Logic Inside Tests
Tests should be boring.
That is a compliment.
Bad:
for (...) {
if (...) {
// calculate expected result dynamically
}
}
Now your test itself contains business logic.
Which raises an uncomfortable question:
Who tests the test?
Usually, explicit expected values are easier to reason about.
One Test, One Reason to Fail
Imagine a single test checks:
User creation
Email delivery
Database persistence
Analytics event
Notification
Profile generation
It fails.
Why?
Good luck.
Tests should usually be focused enough that a failure tells you something useful immediately.
A good test name might be:
it("rejects checkout when inventory is unavailable");
Instead of:
it("works");
One of those helps at 2 AM.
The other becomes a personal attack.
CI Turns Tests Into a Safety System
Tests sitting on a developer's laptop aren't enough.
Modern teams connect tests to Continuous Integration.
A typical pipeline might look like:
Developer pushes code
↓
Lint
↓
Unit Tests
↓
Integration Tests
↓
Coverage
↓
Security Checks
↓
Build
↓
E2E Tests
↓
Deploy
A pull request might require:
Unit tests ✅
Integration tests ✅
Coverage threshold ✅
Static analysis ✅
Security scan ✅
Build ✅
before merging.
This turns testing from:
Something developers should remember to do.
into:
Something the engineering system automatically enforces.
Why Fast Feedback Matters
Imagine:
Unit tests → 20 seconds
Integration → 4 minutes
E2E → 25 minutes
Running everything sequentially before showing any result would be frustrating.
Instead:
Fast tests first
↓
Cheap failures detected quickly
↓
Expensive tests later
There is no reason to spend 25 minutes running E2E tests if:
calculateTax()
already fails a unit test after seven seconds.
Testing Microservices Is Harder
Now replace one application with:
Frontend
↓
API Gateway
↓
User Service
Order Service
Payment Service
Inventory Service
Notification Service
Analytics Service
Suddenly testing becomes distributed.
Questions appear:
What if Payment Service is down?
What if Inventory Service is slow?
What if Order Service retries the same request?
What if an event arrives twice?
What if an event arrives out of order?
What if two services use incompatible API versions?
This is where:
Integration testing
Contract testing
API testing
Event testing
Resilience testing
Observability
become increasingly important.
Contract Testing
Imagine:
Order Service
↓
Payment Service
Order Service expects:
{
"status": "success"
}
Payment Service changes its response:
{
"paymentStatus": "success"
}
Both services may pass their own unit tests.
Production still breaks.
Contract testing checks whether interacting services continue to agree on their interface.
Tools like Pact exist for this type of problem.
Testing Non-Functional Requirements
Testing isn't limited to:
Button works.
API returns result.
Database saves record.
Production systems also have requirements like:
99.9% availability
Handles 10,000 users
Recovers after node failure
Backups restore successfully
Continues operating with degraded services
So your testing strategy may also need:
Reliability testing
Availability testing
Scalability testing
Resilience testing
Failover testing
Disaster recovery testing
Backup/restore testing
Network failure simulation
Latency injection
Load-shedding testing
Graceful degradation verification
What Happens If Redis Dies?
This is a much more useful testing question than:
Is Redis connected?
Suppose Redis handles caching.
Redis fails.
Does the application:
Crash completely?
or:
Fall back to the database?
Maybe performance becomes slower, but the core experience continues.
That is graceful degradation.
Testing real systems increasingly means testing failures intentionally.
What If the Network Becomes Slow?
Distributed applications depend on networks.
Networks fail.
Requests can:
Time out
Arrive late
Be duplicated
Be dropped
Arrive out of order
So mature testing strategies don't only test perfect networks.
They deliberately simulate bad ones.
This is a major shift in mindset:
Don't only test the system in the environment you hope exists.
Test the environment that eventually will exist.
The Testing Ice Cream Cone Anti-Pattern
Imagine a test suite with:
Very few unit tests
Some integration tests
Huge number of E2E/manual tests
That's sometimes described as an ice cream cone.
Why is it problematic?
Because most of the confidence depends on expensive, slow tests.
You might end up with:
2-hour pipelines
Flaky browsers
Hard-to-debug failures
High maintenance cost
A balanced test strategy tries to catch defects as cheaply as practical.
Testing Legacy Code
Now imagine joining a codebase with:
500,000 lines of code
Almost no tests
Business-critical behavior
Very little documentation
Should you rewrite everything?
Probably not.
One useful approach is characterization testing.
Instead of initially asking:
What should this code do?
you capture:
What does this code currently do?
Then tests protect existing behavior while you gradually improve the system.
This can give you enough safety to refactor without accidentally destroying undocumented business logic.
Debugging a Failing Test
When a test fails, avoid immediately changing the assertion just to make it green.
Investigate.
A useful flow is:
Read failure message
↓
Check stack trace
↓
Run failing test alone
↓
Reproduce consistently
↓
Inspect test data
↓
Inspect mocks
↓
Check environment
↓
Debug application logic
↓
Find root cause
Common tricky cases include:
Local passes → CI fails
Individual test passes → suite fails
Fails only at midnight
Fails only in a different timezone
Fails only under parallel execution
Fails because a previous test polluted state
AI and the Future of Software Testing
And Then AI Entered the Room
This is where software testing becomes especially interesting in 2026.
AI can generate:
Functions
APIs
Database queries
Components
Unit tests
Mocks
Fixtures
E2E scripts
Documentation
Refactors
And this is incredibly useful.
You can ask an AI coding tool:
Generate boundary tests for this validator.
and get useful scenarios in seconds.
Or:
Write Playwright tests for this checkout flow.
Or:
Identify edge cases in this API.
AI can dramatically reduce the mechanical work involved in testing.
But there is one subtle problem.
Imagine AI writes this requirement incorrectly:
Users under 18 cannot register.
The real requirement was:
Users under 16 cannot register.
AI writes:
if (age < 18) {
throw new Error("Too young");
}
Then AI generates tests:
expect(register(17)).toThrow();
expect(register(18)).not.toThrow();
Tests:
PASS ✅
Coverage:
100% ✅
Code review:
Looks clean ✅
Requirement:
WRONG ❌
That is the scary part.
The implementation and the tests can agree perfectly...
and both be wrong.
AI Can Automate Tests. It Cannot Define Intent for Us
This is the distinction I keep coming back to.
AI is very good at asking:
What tests could be written for this implementation?
Engineers still need to ask:
Is this implementation solving the right problem?
That's why AI-generated tests still need human review.
Questions worth asking include:
Does this test reflect a real requirement?
Did AI copy implementation assumptions into the test?
Are edge cases missing?
Are mocks realistic?
Does this test challenge the code?
Or is the test merely confirming what the code already assumes?
The biggest danger may not be AI writing obviously broken tests.
Those are easy to catch.
The bigger danger is AI writing tests that look extremely convincing.
A Better Mental Model for AI-Assisted Testing
Instead of:
AI writes code
↓
AI writes tests
↓
Tests pass
↓
Ship
I prefer thinking about it like this:
Requirements
↓
AI generates implementation
↓
AI helps generate tests
↓
Human validates intent
↓
Tests challenge assumptions
↓
CI enforces rules
↓
Production provides feedback
↓
Regression tests capture failures
↓
System improves
AI can accelerate almost every step.
But acceleration isn't the same as correctness.
Production Is the Final Reality Check
You can simulate a lot.
But production will always introduce situations you did not expect.
Maybe:
A customer uploads a 600 MB image.
An API suddenly responds slowly.
Traffic jumps 40x.
A database replica falls behind.
Users discover a strange workflow.
An external dependency changes behavior.
A race condition appears once every 50,000 requests.
This is why testing connects naturally to:
Monitoring
Logging
Tracing
Metrics
Error tracking
Incident response
Production feedback should feed back into your test suite.
That creates a loop:
Build
↓
Test
↓
Deploy
↓
Observe
↓
Learn
↓
Add regression test
↓
Improve
↓
Repeat
My Testing Mental Model
After going through all of these concepts, this is the simplest model I've found useful:
Requirements
↓
Understand expected behavior
↓
Identify risks
↓
Unit tests
↓
Integration tests
↓
API / contract tests
↓
Critical E2E tests
↓
Performance tests
↓
Security tests
↓
CI enforcement
↓
Production monitoring
↓
Regression tests
↓
Repeat
Not every application needs every testing technique.
A small portfolio site doesn't need the testing infrastructure of Netflix.
A banking system shouldn't use the testing strategy of a weekend todo app.
Testing is an engineering tradeoff.
You balance:
Confidence
Speed
Risk
Complexity
Cost
Maintenance
What Should Developers Actually Know for Interviews?
You probably don't need to memorize every testing framework ever created.
But you should be able to explain the reasoning.
For example:
What Is the Testing Pyramid?
Explain why we typically have many unit tests, fewer integration tests, and fewer E2E tests.
Mock vs Stub?
Explain their purpose and when you'd prefer real dependencies.
How Would You Test a REST API?
Talk about:
Success cases
Validation
Authentication
Authorization
Errors
Boundaries
Database state
Rate limits
Contracts
How Would You Test a Login System?
Don't just say:
Correct username/password.
Think about:
Wrong password
Missing fields
Locked user
Expired token
Brute-force protection
Rate limiting
Session expiration
Authorization
Concurrent sessions
How Do You Handle Flaky Tests?
Discuss:
Reproduction
Isolation
Timing
Shared state
Network dependencies
Correct waits
Fixing rather than permanently ignoring them
What's a Good Coverage Percentage?
The strongest answer usually isn't:
100%.
Instead:
Coverage is useful as a signal, but meaningful behavioral coverage and risk coverage matter more than chasing a percentage.
The Biggest Lesson I Learned
Before studying testing deeply, I thought:
Testing = Write tests until they're green.
Now I think:
Testing = Build evidence that the system behaves correctly under conditions that matter.
That is a very different mindset.
And maybe the biggest shift is this:
Don't ask only:
Does my code work?
Ask:
When doesn't it work?
Ask:
What assumption am I making?
Ask:
What happens at the boundary?
Ask:
What happens when the dependency fails?
Ask:
What happens when two things happen at once?
Ask:
What happens when someone intentionally abuses this?
Ask:
What happens when traffic is 100x larger?
Ask:
Does this test verify the requirement, or merely repeat the implementation?
Those questions are where testing starts becoming engineering.
Final Thoughts
Software testing isn't about writing hundreds of expect() statements.
It's about confidence.
Not fake confidence from:
Tests: 2,421 passed ✅
Coverage: 98% ✅
but meaningful confidence that:
Users can complete critical workflows.
Invalid inputs are rejected.
Services communicate correctly.
Failures are handled safely.
Security boundaries hold.
Performance remains acceptable.
Old bugs don't return.
New changes don't silently break existing behavior.
AI will probably make writing tests dramatically easier.
It will generate unit tests.
Suggest edge cases.
Build fixtures.
Create mocks.
Write Playwright flows.
Analyze failures.
Maybe even automatically repair some broken tests.
But that makes engineering judgment more important, not less.
Because if software can be generated faster than humans can manually inspect it, our ability to verify behavior becomes one of the most important parts of software development.
The future might not simply be:
AI writes more code.
It might be:
Engineers become much better at proving whether generated code deserves to be trusted.
And perhaps that's the real purpose of testing.
Not proving perfection.
Building enough evidence to confidently ship something into an imperfect world.
Quick Testing Cheat Sheet
Unit Testing
→ Does this individual piece work?
Integration Testing
→ Do these pieces work together?
API Testing
→ Does the interface behave correctly?
E2E Testing
→ Can the user complete the journey?
Regression Testing
→ Did new code break old behavior?
Performance Testing
→ Does it still work under pressure?
Security Testing
→ Can someone misuse or exploit it?
Smoke Testing
→ Is the build basically alive?
TDD
→ Test → Implement → Refactor
BDD
→ Given → When → Then
Coverage
→ What code did our tests execute?
Mutation Testing
→ Would our tests detect incorrect code?
CI Testing
→ Automatically protect every change.
And the simplest rule I want to remember:
Don't write tests just to make the test suite green. Write tests that would actually scare you if they failed.
Keep Learning
If you're learning software engineering too, I hope this gives you a clearer mental model of where testing fits into the bigger picture.
I'm continuing to dive deeper into testing, debugging, system design, distributed systems, and the engineering concepts that sit underneath the frameworks we use every day.
If you found this useful, drop a comment:
What's the hardest bug you've encountered that somehow survived the test suite?
I'd genuinely love to hear the stories. 👀
Top comments (0)