DEV Community

Cover image for How Developers Think About Software Testing in the AI Era
Dhruv Patel
Dhruv Patel

Posted on

How Developers Think About Software Testing in the AI Era

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 ✅
Enter fullscreen mode Exit fullscreen mode

does not necessarily mean this:

The software is correct ✅
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

We test:

divide(10, 2);
Enter fullscreen mode Exit fullscreen mode

Expected result:

5
Enter fullscreen mode Exit fullscreen mode

The test passes.

Great.

But what about:

divide(10, 0);
divide(null, 2);
divide("hello", 5);
divide(undefined, undefined);
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Multiply that by:

Browsers
Operating systems
Network conditions
Permissions
Database states
User states
Concurrent requests
Third-party services
Enter fullscreen mode Exit fullscreen mode

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?
Enter fullscreen mode Exit fullscreen mode

Testing introduces another mindset:

How can I make this fail?
Enter fullscreen mode Exit fullscreen mode

Suppose we're testing login.

Most developers start here:

Valid email
+
Valid password
=
Login succeeds
Enter fullscreen mode Exit fullscreen mode

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?
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Example:

function calculateDiscount(price, percentage) {
  return price - price * (percentage / 100);
}
Enter fullscreen mode Exit fullscreen mode

A unit test could look like:

test("applies a 20% discount", () => {
  expect(calculateDiscount(100, 20)).toBe(80);
});
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

Depending on our requirements, we might also test:

Negative price
Negative percentage
Discount > 100%
Non-numeric values
Null
Undefined
Enter fullscreen mode Exit fullscreen mode

Good unit tests should generally be:

Fast
Independent
Deterministic
Readable
Self-validating
Focused
Enter fullscreen mode Exit fullscreen mode

Arrange → Act → Assert

One of the easiest ways to structure tests is AAA.

Arrange

Prepare everything needed.

const price = 100;
const discount = 20;
Enter fullscreen mode Exit fullscreen mode
Act

Execute the behavior.

const result = calculateDiscount(price, discount);
Enter fullscreen mode Exit fullscreen mode
Assert

Verify the result.

expect(result).toBe(80);
Enter fullscreen mode Exit fullscreen mode

Complete test:

test("applies discount correctly", () => {
  // Arrange
  const price = 100;
  const discount = 20;

  // Act
  const result = calculateDiscount(price, discount);

  // Assert
  expect(result).toBe(80);
});
Enter fullscreen mode Exit fullscreen mode

Another popular format is:

Given
When
Then
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Every piece could work independently.

Yet this connection could still be broken:

Service → Database
Enter fullscreen mode Exit fullscreen mode

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();
});
Enter fullscreen mode Exit fullscreen mode

Now we're testing more than one function.

We're checking the interaction between:

HTTP layer
Application logic
Database
Enter fullscreen mode Exit fullscreen mode

3. API Testing

API testing deserves special attention because APIs often sit at the boundaries between systems.

Imagine:

POST /api/orders
Enter fullscreen mode Exit fullscreen mode

A weak test checks:

Status = 201
Enter fullscreen mode Exit fullscreen mode

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?
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

Then negative tests:

No token → 401

Wrong permission → 403

Product missing → 404

Quantity = 0 → 400

Malformed body → 400
Enter fullscreen mode Exit fullscreen mode

Then boundaries:

quantity = 1

quantity = maximum allowed

quantity > maximum allowed
Enter fullscreen mode Exit fullscreen mode

Then behavior:

Product out of stock

Payment rejected

Database unavailable

Duplicate order request
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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();
});
Enter fullscreen mode Exit fullscreen mode

Tools commonly used include:

Playwright
Cypress
Selenium
Puppeteer
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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   \
    /__________________\
Enter fullscreen mode Exit fullscreen mode

The basic philosophy:

Many unit tests
Some integration tests
Few E2E tests
Enter fullscreen mode Exit fullscreen mode

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 ✅
Enter fullscreen mode Exit fullscreen mode

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?
Enter fullscreen mode Exit fullscreen mode

Think:

Smoke → Broad and shallow

Sanity → Narrow and focused
Enter fullscreen mode Exit fullscreen mode

Regression Testing

You fix Bug #427:

Users with apostrophes in their names cannot register.
Enter fullscreen mode Exit fullscreen mode

You add a test.

test("allows apostrophes in user names", () => {
  const result = validateName("O'Connor");
  expect(result).toBe(true);
});
Enter fullscreen mode Exit fullscreen mode

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 = {};
Enter fullscreen mode Exit fullscreen mode

Stub

Provides predefined responses.

const userRepository = {
  findById: () => ({
    id: 1,
    name: "Dhruv"
  })
};
Enter fullscreen mode Exit fullscreen mode

Spy

Records interactions.

You might ask:

Was this function called?

How many times?

With which arguments?
Enter fullscreen mode Exit fullscreen mode

Mock

Usually includes expectations about interactions.

expect(sendEmail).toHaveBeenCalledWith(
  "user@example.com"
);
Enter fullscreen mode Exit fullscreen mode

Fake

A working but simplified implementation.

For example:

Production → PostgreSQL

Testing → In-memory database
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

But your mock returns:

{
  "id": "abc123",
  "success": true
}
Enter fullscreen mode Exit fullscreen mode

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
 ↺
Enter fullscreen mode Exit fullscreen mode

Red

Write a failing test.

test("adds two numbers", () => {
  expect(add(2, 3)).toBe(5);
});
Enter fullscreen mode Exit fullscreen mode

There is no implementation yet.

Test fails.

Green

Write the smallest implementation needed.

function add(a, b) {
  return a + b;
}
Enter fullscreen mode Exit fullscreen mode

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?
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The structure:

Given → Context

When → Action

Then → Expected outcome
Enter fullscreen mode Exit fullscreen mode

This can help bridge communication between:

Developers
QA engineers
Product owners
Business stakeholders
Enter fullscreen mode Exit fullscreen mode

Tools include:

Cucumber
Behave
SpecFlow
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Negative testing asks:

Does the system behave correctly with invalid or unexpected input?

Wrong password
→ Login rejected
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Testing this:

age = 50
Enter fullscreen mode Exit fullscreen mode

is useful.

But these values are often more interesting:

17 ❌

18 ✅

19 ✅

99 ✅

100 ✅

101 ❌
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Performance Testing

Your API can be logically correct and still be unusable.

Suppose:

GET /products
Enter fullscreen mode Exit fullscreen mode

returns the correct products.

But:

Response time = 12 seconds
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Questions:

What's the response time?

What's the throughput?

How many requests fail?

How much CPU is used?

How much memory?
Enter fullscreen mode Exit fullscreen mode

Stress Testing

Stress testing goes beyond normal capacity.

Maybe expected load is:

5,000 users
Enter fullscreen mode Exit fullscreen mode

We push:

10,000

20,000

50,000
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

This can happen from:

Product launches
Breaking news
Ticket sales
Viral content
Flash sales
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

Soak testing runs the system for an extended period to discover issues such as:

Memory leaks

Resource leaks

Connection leaks

Slow degradation
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 ✅
Enter fullscreen mode Exit fullscreen mode

but this works:

' OR '1'='1
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The user is authenticated.

But what happens if they change the URL?

GET /api/users/101
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

Test:

test("25 is an adult", () => {
  expect(isAdult(25)).toBe(true);
});
Enter fullscreen mode Exit fullscreen mode

The function may have:

100% line coverage
Enter fullscreen mode Exit fullscreen mode

But we never tested:

17

18

Negative values

Null

Strings
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Branch coverage can be more useful than simple line coverage.

Example:

if (user.isAdmin) {
  showAdminPanel();
} else {
  showDashboard();
}
Enter fullscreen mode Exit fullscreen mode

A test that only covers:

isAdmin = true
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

A mutation testing tool might temporarily change it to:

if (age > 18)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Good start.

But what about:

D

Dhruv Patel

O'Connor

José

李明

😀

""

10,000 characters
Enter fullscreen mode Exit fullscreen mode

Real users produce messy data.

Test data strategies include:

Hardcoded data

Factories

Builders

Fixtures

Seed data

Random data

Generated data

Anonymized production data
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Flaky tests are especially harmful because they destroy trust.

Eventually a developer sees:

CI FAILED
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

If Test B runs first:

FAIL
Enter fullscreen mode Exit fullscreen mode

If Test C runs before B:

FAIL
Enter fullscreen mode Exit fullscreen mode

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}`;
}
Enter fullscreen mode Exit fullscreen mode

A good test asks:

Does getFullName return "Dhruv Patel"?
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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");
Enter fullscreen mode Exit fullscreen mode

Instead of:

it("works");
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

A pull request might require:

Unit tests ✅

Integration tests ✅

Coverage threshold ✅

Static analysis ✅

Security scan ✅

Build ✅
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Running everything sequentially before showing any result would be frustrating.

Instead:

Fast tests first
       ↓
Cheap failures detected quickly
       ↓
Expensive tests later
Enter fullscreen mode Exit fullscreen mode

There is no reason to spend 25 minutes running E2E tests if:

calculateTax()
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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?
Enter fullscreen mode Exit fullscreen mode

This is where:

Integration testing

Contract testing

API testing

Event testing

Resilience testing

Observability
Enter fullscreen mode Exit fullscreen mode

become increasingly important.

Contract Testing

Imagine:

Order Service
     ↓
Payment Service
Enter fullscreen mode Exit fullscreen mode

Order Service expects:

{
  "status": "success"
}
Enter fullscreen mode Exit fullscreen mode

Payment Service changes its response:

{
  "paymentStatus": "success"
}
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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?
Enter fullscreen mode Exit fullscreen mode

or:

Fall back to the database?
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

And this is incredibly useful.

You can ask an AI coding tool:

Generate boundary tests for this validator.
Enter fullscreen mode Exit fullscreen mode

and get useful scenarios in seconds.

Or:

Write Playwright tests for this checkout flow.
Enter fullscreen mode Exit fullscreen mode

Or:

Identify edge cases in this API.
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

The real requirement was:

Users under 16 cannot register.
Enter fullscreen mode Exit fullscreen mode

AI writes:

if (age < 18) {
  throw new Error("Too young");
}
Enter fullscreen mode Exit fullscreen mode

Then AI generates tests:

expect(register(17)).toThrow();
expect(register(18)).not.toThrow();
Enter fullscreen mode Exit fullscreen mode

Tests:

PASS ✅
Enter fullscreen mode Exit fullscreen mode

Coverage:

100% ✅
Enter fullscreen mode Exit fullscreen mode

Code review:

Looks clean ✅
Enter fullscreen mode Exit fullscreen mode

Requirement:

WRONG ❌
Enter fullscreen mode Exit fullscreen mode

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?
Enter fullscreen mode Exit fullscreen mode

Engineers still need to ask:

Is this implementation solving the right problem?
Enter fullscreen mode Exit fullscreen mode

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?
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

This is why testing connects naturally to:

Monitoring

Logging

Tracing

Metrics

Error tracking

Incident response
Enter fullscreen mode Exit fullscreen mode

Production feedback should feed back into your test suite.

That creates a loop:

Build

↓

Test

↓

Deploy

↓

Observe

↓

Learn

↓

Add regression test

↓

Improve

↓

Repeat
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

How Would You Test a Login System?

Don't just say:

Correct username/password.
Enter fullscreen mode Exit fullscreen mode

Think about:

Wrong password

Missing fields

Locked user

Expired token

Brute-force protection

Rate limiting

Session expiration

Authorization

Concurrent sessions
Enter fullscreen mode Exit fullscreen mode

How Do You Handle Flaky Tests?

Discuss:

Reproduction

Isolation

Timing

Shared state

Network dependencies

Correct waits

Fixing rather than permanently ignoring them
Enter fullscreen mode Exit fullscreen mode

What's a Good Coverage Percentage?

The strongest answer usually isn't:

100%.
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

Now I think:

Testing = Build evidence that the system behaves correctly under conditions that matter.
Enter fullscreen mode Exit fullscreen mode

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% ✅
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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)