惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

T
Threat Research - Cisco Blogs
Microsoft Security Blog
Microsoft Security Blog
aimingoo的专栏
aimingoo的专栏
WordPress大学
WordPress大学
Recorded Future
Recorded Future
The Register - Security
The Register - Security
Microsoft Azure Blog
Microsoft Azure Blog
Stack Overflow Blog
Stack Overflow Blog
爱范儿
爱范儿
大猫的无限游戏
大猫的无限游戏
Blog — PlanetScale
Blog — PlanetScale
H
Help Net Security
Webroot Blog
Webroot Blog
Help Net Security
Help Net Security
Forbes - Security
Forbes - Security
H
Hacker News: Front Page
cs.CV updates on arXiv.org
cs.CV updates on arXiv.org
云风的 BLOG
云风的 BLOG
Hacker News: Ask HN
Hacker News: Ask HN
Security Archives - TechRepublic
Security Archives - TechRepublic
Google Online Security Blog
Google Online Security Blog
Attack and Defense Labs
Attack and Defense Labs
T
Tailwind CSS Blog
J
Java Code Geeks
C
CXSECURITY Database RSS Feed - CXSecurity.com
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
Cyberwarzone
Cyberwarzone
小众软件
小众软件
G
Google Developers Blog
SecWiki News
SecWiki News
V
V2EX
C
Cybersecurity and Infrastructure Security Agency CISA
T
The Blog of Author Tim Ferriss
S
SegmentFault 最新的问题
MyScale Blog
MyScale Blog
S
Security Affairs
AI
AI
S
Securelist
D
Docker
人人都是产品经理
人人都是产品经理
T
Troy Hunt's Blog
罗磊的独立博客
The Hacker News
The Hacker News
阮一峰的网络日志
阮一峰的网络日志
Google DeepMind News
Google DeepMind News
宝玉的分享
宝玉的分享
P
Proofpoint News Feed
P
Proofpoint News Feed
Vercel News
Vercel News
Jina AI
Jina AI

DEV Community

Authentication Security Deep Dive: From Brute Force to Salted Hashing (With Java Examples) Why AI Systems Don’t Fail — They Drift Spilling beans for how i learn for exam😁"Reinforcement Learning Cheat Sheet" I Replaced Chrome with Safari for AI Browser Automation. Here's What Broke (and What Finally Worked) How Python Borrows Other People's Work The $40 Architecture: Processing 1 Billion API Requests with 99.99% Uptime Vibe Coding: A Workflow Guide (From Zero to SaaS) Most webhook security guides protect the wrong side. The scary part is delivery. Headless CMS for TanStack Start: Build a Blog with Cosmic EU Age Verification App "Hacked in 2 Minutes" — What Actually Happened Comfy Cloud’s delete function does not actually remove files Running AI Models on GPU Cloud Servers: A Beginner Guide Event-driven media intelligence with AWS Step Functions and Bedrock I scored 500 AI prompts across 8 quality dimensions — here's what broke How to Call Google Gemini API from Next.js (Free Tier, No Backend Needed) The Portal Protocol: Reclaiming Human Connection in the Age of AI How to Fix Your Team's Scattered Knowledge Problem With a Self-Hosted Forum Intro to tc Cloud Functors: A Graph-First Mental Model for the Modern Cloud Designing Multi-Tenant Backends With Both Ownership and Team Access I Built a Neumorphic CSS Library with 77+ Components — Here's What I Learned PostgreSQL Performance Optimization: Why Connection Pooling Is Critical at Scale Cómo construí un SaaS multi-rubro para gestionar expensas en Argentina con FastAPI + Vue 3 🚀 I Built an Ethical Hacking Scanner Tool – Open Source Project I Replaced /usage and /context in Claude Code With a Single Statusline A Pythonic Way to Handle Emails (IMAP/SMTP) with Auto-Discovery and AI-Ready Design I Collected 8.9 Million Polymarket Price Points — Here's What I Found About How Markets Really Move EcoTrack AI — Carbon Footprint Tracker & Dashboard Everyone's Using AI. No One Agrees How. 5 self-hosted ebook managers worth trying in 2026 Building Your First AI Agent with LangChain: From Chatbot to Autonomous Assistant Common SOC 2 Failures (Real World) Stop Vibe-Checking Your AI App: A Practical Guide to Evals How to Use SonarQube and SonarScanner Locally to Level Up Your Code Quality Your Next To-Do App Is Dead — I Replaced Mine with an OpenClaw AI Sign a Nostr event in 60 lines of Python using coincurve — no nostr-sdk, no nbxplorer, no rust toolchain ITGC Audit Explained Like You’re in Big 4 Patch Tuesday abril 2026: Microsoft parcha 163 vulnerabilidades y un zero-day en SharePoint Stop scraping everything: a better way to track competitor price changes Listing on MCPize + the Official MCP Registry while routing payments OUTSIDE the marketplace — how I kept 100% of my x402 revenue Building an AI-Powered Risk Intelligence System Using Serverless Architecture Why We Ripped Function Overloading Out of Our AI Toolchain Testing AI-Generated Code: How to Actually Know If It Works SaaS Churn Is Killing Your Business. Here Is What to Do About It (Without a Support Team) The Speed of AI Is No Longer Linear - And Self-Improving Models Are Why How to Implement RBAC for MCP Tools: A Practical Guide for Engineering Teams From Standard Quote to Persuasive Proposal: AI Automation for Arborists I built a CLI that scaffolds complete multi-tenant SaaS apps Axios CVE-2025–62718: The Silent SSRF Bug That Could Be Hiding in Your Node.js App Right Now The dashboard that ended our friendship Data Pipelines Explained Simply (and How to Build Them with Python) The Hidden Cost of AI Systems Nobody Talks About. undefined vs undeclared, and how typeof behaves Switching from file-based jobs to NATS/Kafka in Rust without changing code io_uring Adventures: Rust Servers That Love Syscalls Why Agentic AI is Killing the Traditional Database The POUR principles of web accessibility for developers and designers Quantum Neural Network 3D — A Deep Dive into Interactive WebGL Visualization How To Install Caveman In Codex On macOS And Windows Automation Pipeline Reliability: Why Your Workflow Breaks When Nobody Is Watching I Built an 'Open World' AI Coding Agent — It Works From ANY Folder From Freelancing to Product: A Tech Service Company's SaaS Transformation China's AI Giants: Adding Tencent Hunyuan & ByteDance Doubao to AI University (74 Providers) On the Vibe Coders and Their Lies clerk: Auto-Summarize Your Claude Code Sessions AI Weekly — 2026/04/10–04/17 | The Model Lockdown Is Here, but the Toolchain Is the Real Battleground AI 週報 — 2026/04/10–2026/04/17 模型封鎖潮來了,但工具鏈才是真戰場 Maybe this is how Open-Source apps are born... 🚀 Fine-Tune LLMs with LoRA and QLoRA: 2026 Guide tRPC v11 + Next.js App Router: End-to-End Type Safety Without the Boilerplate ShadCN UI in 2026: Why I Stopped Installing Component Libraries and Started Owning My Components SaaS Billing in React Server Components: Stripe + Supabase Without a Single `useEffect` Join our DEV Weekend Challenge — $1,000 in Prizes Across TEN winners! Submissions Due April 20 at 6:59 AM UTC. Implementing FSRS Spaced Repetition in Flutter + Supabase — Adding Memory Science to an AI Learning App "I Texted My Localhost From the Train — Claude Code Fixed the Bug Before I Got Home" I Built a Sales Prep AI and It Went Deeper Than Expected Design to Code #2: One JSON, Eleven Outputs Solving the 100M-Row Problem: A Summary Table Pattern for High-Volume Push Notification Logs Flutter Web With Wasm: What Actually Changes For Developers I Built 50 Royalty-Free Soundtracks for My Side Project in a Weekend Using AI Music Generation The Vibe Coding Security Checklist: 7 Things to Check Before You Ship Stop Letting Googlebot Guess Fix Your React App's SEO Right Desconstruindo o Streaming do LinkedIn: Como Criar um Engine de Extração de Vídeo de Alta Performance com HLS e FFmpeg (EDA Part-1) EDA (Exploratory Data Analysis) Explained With Real Life — Why Looking at Your Data Is the Most Important Step in Machine Learning Brand Relationship Management at Scale: Our 4-Touch Outreach System for 200+ Brands Why String.fromEnvironment() Might Return an Empty String in Dart JGuardrails 1.0.0 — Hardening Java LLM Apps Against Jailbreaks, Toxicity, and Prompt Injection Plan and Schedule a Full Week of Threads Content From One Claude Conversation Coding Cat Oran Ep3, Five Tables Changed Everything Updated: BFF Pattern I'm done watching freelancers get buried by 200 proposals. So I'm building the alternative. This is my first post BFS Algorithm in Java Step by Step Tutorial with Examples Tracking LLM Pricing Monthly: An Open Dataset for 22 AI Models How We Measure Content ROI on a Comparison Site: Revenue Attribution Without Perfect Data Introducing Nova AI Ops: The AI-Native Operating System for SRE Teams I built a free desktop video downloader for Windows — Grabbit How Talkie OCR Helps Vision-Impaired & Dyslexic Users Read the World Around Them VRCFaceTracking安装和iPhone面捕配置教程,有bug Even CrowdStrike Can't See Your Agents The Automation Gold Rush: What n8n Workflows and Claude Are Opening Up for Developers Right Now
Writing Testable Code: Common Anti-Patterns and How to Fix Them
Mark Adel · 2026-04-26 · via DEV Community

When code is hard to test, it is usually a design problem, not a testing problem. Code becomes difficult to test for the same reasons it becomes difficult to maintain. This article looks at eight common anti-patterns that make code harder to test and how to improve them. There are other anti-patterns, but in my experience writing and reviewing code, these are the most common.

Please note that these anti-patterns mostly hurt unit testing, where the goal is to test pieces of business logic in isolation. Other types of testing, such as integration and end-to-end testing, may be less affected because they test larger parts of the system together.

The advice in this guide is aimed at production codebases that will be maintained over time. Applying it to one-time scripts or throwaway prototypes would be overkill.

Table of contents

Prerequisite: Terms used in this guide

  • Business logic: Business logic or Domain logic are the real-world rules that define how application data can be created, stored, changed, and used, separate from infrastructure or UI details.
  • Infrastructure: Code that talks to the outside world, such as databases, file storage, external APIs, queues, and caches.
  • Dependency: Something a class or method needs in order to do its work.
  • Hard-coded dependency: A dependency created directly inside the code, such as with new.
  • Dependency injection: Passing a dependency into a class or a method instead of hard-coding it.
  • Mock: A test object that can replace a real dependency, return prepared values, and verify that expected calls happened.
  • Fake: A simple working implementation used in tests instead of a real dependency, such as an in-memory repository.
  • Side effect: Anything a method does beyond returning a value, such as saving data, sending email, changing state, or making an HTTP request.
  • Deterministic/Non-deterministic code: Deterministic code gives the same output for the same input; non-deterministic code can give different results for the same input.

Now let's look at the common anti-patterns that make code harder to test and how to fix them.

1. Hard-coded dependencies

class OrderService {
  public void placeOrder(Order order) {
    OrderRepository orderRepo = new OrderRepository();
    PaymentGateway paymentGateway = new PaymentGateway();

    paymentGateway.charge(order.getCustomerId(), order.getTotal());
    orderRepo.save(order);
  }
}

Enter fullscreen mode Exit fullscreen mode

Why this is hard to test

The method creates its own dependencies with new. That means a test for placeOrder always uses the real repository and the real payment gateway. There is no way to substitute a fake or a mock because the class or the method does not accept those dependencies as inputs.

To test this class, you either need a real database and a real payment service running somewhere, or you need specialized tooling to replace what new returns.

The fix

Inject the dependencies. Let the caller decide which implementations to use.

class OrderService {
  private final OrderRepository orderRepo;
  private final PaymentGateway paymentGateway;

  OrderService(OrderRepository orderRepo, PaymentGateway paymentGateway) {
    this.orderRepo = orderRepo;
    this.paymentGateway = paymentGateway;
  }

  public void placeOrder(Order order) {
    paymentGateway.charge(order.getCustomerId(), order.getTotal());
    orderRepo.save(order);
  }
}

Enter fullscreen mode Exit fullscreen mode

Why it is now easy to test

Tests can pass a fake or mock repository and payment gateway. Production code passes the real ones. The class does not care which, because the dependencies are no longer hard-coded.

Injection does not automatically mean introducing an interface. There is a section at the end of the article that covers when I think an interface is worth introducing.

Also, not every dependency necessarily needs to be injected.

2. Hidden time and randomness

class TokenService {
  public Token issue(String userId) {
    LocalDateTime issuedAt = LocalDateTime.now();
    String id = "T-" + new Random().nextInt(1_000_000);
    return new Token(id, userId, issuedAt);
  }
}

Enter fullscreen mode Exit fullscreen mode

Why this is hard to test

The method depends on two things that change on every call: the current time and a random number. Because the output is different every time, tests can only check weak things like "token is not null" or "ID starts with T-". Those assertions pass even when the code is broken.

This is sometimes called non-determinism: given the same input, the function gives you a different result on each call. Non-deterministic code is hard to test.

The fix

Inject the clock and the random provider, so the caller decides what they return:

class TokenService {
  private final Clock clock;
  private final RandomProvider random;

  TokenService(Clock clock, RandomProvider random) {
    this.clock = clock;
    this.random = random;
  }

  public Token issue(String userId) {
    return new Token(
      "T-" + this.random.nextInt(1_000_000),
      userId,
      LocalDateTime.now(this.clock)
    );
  }
}

Enter fullscreen mode Exit fullscreen mode

Why it is now easy to test

A test can pass a fixed clock and a fake RandomProvider that always returns a fixed value like 123456. The token now has the same value every time, so the test can check every token field exactly. Nothing hidden, nothing flaky.

3. Global mutable state

class AppConfig {
  public static boolean DISCOUNT_ENABLED = true;
}

class PricingService {
  public double finalPrice(double basePrice) {
    return AppConfig.DISCOUNT_ENABLED ? basePrice * 0.9 : basePrice;
  }
}

Enter fullscreen mode Exit fullscreen mode

Why this is hard to test

The behavior depends on a global mutable flag. Any piece of code, anywhere in the program, can change it. Worse, tests can affect each other: one test updates the flag, the next test runs with the updated value, and results start depending on the order the tests are run in.

The fix

Avoid global mutable state. Instead, make configuration immutable and inject it into the service:

class AppConfig {
  public final boolean discountEnabled;

  AppConfig(boolean discountEnabled) {
    this.discountEnabled = discountEnabled;
  }
}

class PricingService {
  private final AppConfig config;

  PricingService(AppConfig config) {
    this.config = config;
  }

  public double finalPrice(double basePrice) {
    return config.discountEnabled ? basePrice * 0.9 : basePrice;
  }
}

Enter fullscreen mode Exit fullscreen mode

Why it is now easy to test

Each test creates its own config and passes it in. The configuration is immutable, so it cannot be changed accidentally by another test or another part of the program.

4. Static calls to side-effecting code

class EmailSender {
  public static void send(String to, String message) {
    // send email
  }
}

class PasswordResetService {
  public void sendResetLink(String email, String link) {
    String message = "Reset your password: " + link;
    EmailSender.send(email, message);
  }
}

Enter fullscreen mode Exit fullscreen mode

Why this is hard to test

The problem here is that PasswordResetService depends directly on a static method that performs I/O. Because the call is hard-coded, a test cannot easily replace it with a mock or fake implementation. Instead, the test is forced either to invoke the real email-sending code or to rely on heavier tooling to intercept the static call.

The fix

Instead of calling the email-sending code statically, inject an EmailSender dependency and call it through the instance:

class EmailSender {
  public void send(String to, String message) {
    // send email
  }
}

class PasswordResetService {
  private final EmailSender emailSender;

  PasswordResetService(EmailSender emailSender) {
    this.emailSender = emailSender;
  }

  public void sendResetLink(String email, String link) {
    String message = "Reset your password: " + link;
    emailSender.send(email, message);
  }
}

Enter fullscreen mode Exit fullscreen mode

Why it is now easy to test

Each test can pass in a mocked EmailSender and verify that the correct email would have been sent, without invoking the real email-sending code.

Please note that pure static helpers are usually not a problem. Static calls become painful when they include side-effecting code.

5. Mixing business logic with I/O

class PricingService {
  public double getDiscountedPrice(String userId, double price) throws IOException {
    URL url = new URL("https://api.example.com/users/" + userId);
    HttpURLConnection conn = (HttpURLConnection) url.openConnection();

    boolean isVip = conn.getResponseCode() == 200;

    double discount;
    if (isVip && price >= 200) {
      discount = 0.25;
    } else if (isVip) {
      discount = 0.10;
    } else {
      discount = 0.0;
    }

    return price * (1 - discount);
  }
}

Enter fullscreen mode Exit fullscreen mode

Why this is hard to test

The method mixes an HTTP call with a pricing rule. The rule has several branches that deserve their own tests, but you cannot exercise any of them without making a real network request.

The fix

Separate the HTTP call from the pricing logic:

class UserClient {
  public boolean isVip(String userId) throws IOException {
    URL url = new URL("https://api.example.com/users/" + userId);
    HttpURLConnection conn = (HttpURLConnection) url.openConnection();
    return conn.getResponseCode() == 200;
  }
}

class PricingService {
  private final UserClient userClient;

  PricingService(UserClient userClient) {
    this.userClient = userClient;
  }

  public double getDiscountedPrice(String userId, double price) throws IOException {
    boolean isVip = userClient.isVip(userId);

    double discount;
    if (isVip && price >= 200) {
      discount = 0.25;
    } else if (isVip) {
      discount = 0.10;
    } else {
      discount = 0.0;
    }

    return price * (1 - discount);
  }
}

Enter fullscreen mode Exit fullscreen mode

Why it is now easy to test

The pricing rule is now separate from the HTTP call, so each branch can be tested without making network requests. PricingService can be tested with a mock UserClient, while UserClient can be covered separately with an integration test if needed.

6. Catching and swallowing exceptions

class UserService {
  private final EmailSender emailSender;

  UserService(EmailSender emailSender) {
    this.emailSender = emailSender;
  }

  public void sendWelcomeEmail(User user) {
    try {
      emailSender.send(user.getEmail(), "Welcome!");
    } catch (Exception e) {
      // exception is ignored or just logged
    }
  }
}

Enter fullscreen mode Exit fullscreen mode

Why this is hard to test

The method hides the failure. If sending the email fails, the code ignores the exception.

There is no clear way for the test to determine whether this method succeeded or failed. The deeper issue is that the method's contract is dishonest: it claims to send a welcome email but silently does nothing on failure. Hard-to-test is the symptom.

The fix

Make the failure part of the method's contract. For example, let the exception stop the flow:

class UserService {
  private final EmailSender emailSender;

  UserService(EmailSender emailSender) {
    this.emailSender = emailSender;
  }

  public void sendWelcomeEmail(User user) throws EmailException {
    emailSender.send(user.getEmail(), "Welcome!");
  }
}

Enter fullscreen mode Exit fullscreen mode

Or return an explicit result:

class UserService {
  private final EmailSender emailSender;

  UserService(EmailSender emailSender) {
    this.emailSender = emailSender;
  }

  public WelcomeResult sendWelcomeEmail(User user) {
    try {
      emailSender.send(user.getEmail(), "Welcome!");
      return WelcomeResult.success();
    } catch (EmailException e) {
      return WelcomeResult.emailFailed();
    }
  }
}

Enter fullscreen mode Exit fullscreen mode

Why it is now easy to test

The failure is now visible to the caller, so the test has something explicit to assert.

In the first version, a test can assert that an email failure threw an exception. In the second version, a test can assert that the result is emailFailed.

7. Business logic trapped inside framework code

@RestController
@RequestMapping("/invoices")
class InvoiceController {
  private final OrderRepository orders;

  InvoiceController(OrderRepository orders) {
    this.orders = orders;
  }

  @GetMapping("/{orderId}")
  public ResponseEntity<Map<String, Object>> calculateInvoice(
    @PathVariable long orderId,
    @RequestParam boolean includeTax
  ) {
    Optional<Order> optionalOrder = orders.findWithItems(orderId);

    if (optionalOrder.isEmpty()) {
      return ResponseEntity
        .status(HttpStatus.NOT_FOUND)
        .body(Map.of("error", "Order not found"));
    }

    Order order = optionalOrder.get();

    BigDecimal subtotal = BigDecimal.ZERO;

    for (OrderItem item : order.getItems()) {
      BigDecimal lineTotal = item.getPrice().multiply(BigDecimal.valueOf(item.getQuantity()));
      subtotal = subtotal.add(lineTotal);
    }

    if (includeTax) {
      BigDecimal tax = subtotal.multiply(new BigDecimal("0.14"));
      subtotal = subtotal.add(tax);
    }

    return ResponseEntity.ok(Map.of("total", subtotal));
  }
}

Enter fullscreen mode Exit fullscreen mode

Why this is hard to test

The controller mixes invoice calculation with Spring-specific details. A test for the total is no longer just "given these order items, expect this total".

Instead, the test has to deal with request parameters, ResponseEntity, HTTP status codes, and response body shape.

Most of that setup and assertion is about Spring details, not invoice calculation.

The fix

Keep the controller focused on the HTTP boundary, and move the invoice calculation into application code:

class InvoiceService {
  private final OrderRepository orders;

  InvoiceService(OrderRepository orders) {
    this.orders = orders;
  }

  public BigDecimal calculateInvoice(long orderId, boolean includeTax) {
    Order order = orders.findWithItems(orderId)
      .orElseThrow(OrderNotFoundException::new);

    BigDecimal subtotal = BigDecimal.ZERO;

    for (OrderItem item : order.getItems()) {
      BigDecimal lineTotal = item.getPrice().multiply(BigDecimal.valueOf(item.getQuantity()));
      subtotal = subtotal.add(lineTotal);
    }

    if (includeTax) {
      BigDecimal tax = subtotal.multiply(new BigDecimal("0.14"));
      return subtotal.add(tax);
    }

    return subtotal;
  }
}

@RestController
@RequestMapping("/invoices")
class InvoiceController {
  private final InvoiceService invoiceService;

  InvoiceController(InvoiceService invoiceService) {
    this.invoiceService = invoiceService;
  }

  @GetMapping("/{orderId}")
  public ResponseEntity<Map<String, Object>> calculateInvoice(
    @PathVariable long orderId,
    @RequestParam boolean includeTax
  ) {
    try {
      BigDecimal total = invoiceService.calculateInvoice(orderId, includeTax);

      return ResponseEntity.ok(Map.of("total", total));
    } catch (OrderNotFoundException e) {
      return ResponseEntity
        .status(HttpStatus.NOT_FOUND)
        .body(Map.of("error", "Order not found"));
    }
  }
}

Enter fullscreen mode Exit fullscreen mode

Why it is now easy to test

InvoiceService can be tested without preparing an HTTP request or inspecting a ResponseEntity. A test can inject a fake order repository, call calculateInvoice, and assert the returned total directly.

Business logic stays in application code. Request handling and responses stay in the framework layer.

8. Business logic hidden inside a large workflow

Private methods are not automatically a problem. In most cases, they should be tested through the public behavior of the class.

The problem appears when a public method does many unrelated things, and an important business rule is buried inside it. Testing that rule now requires setting up the whole workflow.

class CheckoutService {
  private final InventoryService inventory;
  private final PaymentGateway paymentGateway;
  private final EmailSender emailSender;

  CheckoutService(
    InventoryService inventory,
    PaymentGateway paymentGateway,
    EmailSender emailSender
  ) {
    this.inventory = inventory;
    this.paymentGateway = paymentGateway;
    this.emailSender = emailSender;
  }

  public Receipt checkout(Cart cart, Customer customer) {
    inventory.reserve(cart.items());

    int subtotal = 0;

    for (CartItem item : cart.items()) {
      subtotal += item.price() * item.quantity();
    }

    int discount = calculateDiscount(customer, subtotal);
    int total = subtotal - discount;

    paymentGateway.charge(customer.id(), total);
    emailSender.send(customer.email(), "Thanks for your order");

    return new Receipt(total);
  }

  private int calculateDiscount(Customer customer, int subtotal) {
    if (customer.isVip() && subtotal >= 10_000) {
      return 2_000;
    }

    if (customer.isVip()) {
      return 1_000;
    }

    return 0;
  }
}

Enter fullscreen mode Exit fullscreen mode

Why this is hard to test

The discount rule is simple, but it is trapped inside checkout.

To test the VIP discount, the test has to create a cart, prepare inventory reservation, avoid a real payment charge, avoid sending a real email, call checkout, and then inspect the receipt.

Most of that setup has nothing to do with the discount rule.

The fix

Move the independent business rule into a small class with clear inputs and outputs:

class DiscountPolicy {
  public int discountFor(Customer customer, int subtotal) {
    if (customer.isVip() && subtotal >= 10_000) {
      return 2_000;
    }

    if (customer.isVip()) {
      return 1_000;
    }

    return 0;
  }
}

Enter fullscreen mode Exit fullscreen mode

Why it is now easy to test

The discount rule can be tested directly, without inventory, payment, email, or a checkout workflow. Moving it out gives the rule a smaller testing surface and keeps CheckoutService focused on orchestration:

DiscountPolicy policy = new DiscountPolicy();

int discount = policy.discountFor(vipCustomer, 12_000);

assertEquals(2_000, discount);

Enter fullscreen mode Exit fullscreen mode

When it is not necessary to inject dependencies

Not every dependency needs to be injected. A useful rule of thumb is: inject infrastructure, not pure business logic.

Infrastructure is the code that talks to the outside world, such as databases, payment gateways, email services, external APIs, file storage, queues, and caches. You want to be able to swap these in tests.

Pure business logic is the code that does calculations and decisions, such as discount rules, tax math, and validation. You rarely need to replace these in tests. Writing new DiscountCalculator() inside a method is usually fine, because there is no good reason to swap it out. If the calculator has a bug, the test catches it. If the calculator is slow or unreliable, that is already a bigger problem.

When to use an interface and when not to

This is a controversial topic, and the following is my current point of view.

An interface earns its place when you genuinely expect more than one implementation, not just because testing requires it.

Payment gateways are the clearest example. Even if you only have one implementation today, there is a good chance you will have another later, either replacing the current one or running alongside it. That is a real need for polymorphism, so an interface makes sense.

In my experience, database repositories usually do not qualify. It is rare to have multiple implementations of your data layer, and if that does happen, the missing interface will be the least of your problems. The real challenge will be data mapping and migration.

A better rule than "every dependency needs an interface" is this: any dependency that must be replaceable should provide a clear way to replace it.

Where integration tests fit

Writing testable code does not mean every behavior should be tested only with unit tests.

Unit tests are good for checking business logic in isolation. Integration tests are still needed to verify real interactions between modules, databases, APIs, and other external systems.

Relying only on unit tests is an anti-pattern because they cannot catch failures in how components work together.

At the same time, integration tests are slower, harder to debug, and more complex, so they should not replace unit tests.

A good balance is:

  • Many unit tests for fast, precise validation of logic
  • A smaller number of integration tests to verify real-world wiring and behavior

The first two sections of this article explore this topic in more detail.

Testability checklist

Before writing a unit test, ask:

  • Can I control the dependencies?
  • Can I control time and randomness?
  • Can I avoid shared mutable state between tests?
  • Are static calls limited to pure, predictable behavior without side effects?
  • Can I test the business logic without real I/O?
  • Can I observe success and failure clearly?
  • Is business logic separate from framework code?
  • Can I test key business rules without running the whole workflow?

Conclusion

Testable code tends to be easier to read, change, debug, and maintain for the same reasons it is easier to test: fewer hidden dependencies, more predictable behavior, and business logic that is isolated from infrastructure. That is why testability is worth treating as a design goal, not just a testing concern.