In the world of software development, testing is key. We test to ensure our systems behave as expected whether it’s saving data, retrieving it, or verifying that our business logic (especially the complex stuff) actually works.
This blog isn’t about textbook definitions or test-driven development (TDD). Let’s get that out of the way early: I’m not a fan of TDD. It doesn’t make sense to me to write tests for code that doesn’t exist yet. That’s a debate for another time.
What I want to focus on here is testing in the real world — testing that happens during actual application development. The kind where you’re knee-deep in features, fixing bugs, refactoring legacy code, or integrating third-party services.
We’ll talk about:
- Unit testing: when and how it’s useful
- Integration testing: the kind that helps you sleep better at night
- Mocking: where it helps, where it hurts
- And above all, logic testing: how to test complex, branching, real-world logic
🔍 Understanding the User Story
Before we dive into testing, we need to start where real software starts: the user story.
A user story must be clear, and it must come with Acceptance Criteria (AC). This is non-negotiable. Without clarity at this level, the code you write is built on assumptions, and the tests you write are blind guesses.
When the ACs are clear, they guide implementation, they inform design choices, and they help you write focused, meaningful tests.
🎓 Our Example: Registering for a Course (LMS)
To ground this discussion, we’re going to use a common example from a Learning Management System (LMS):
As a user, I want to register for a course, so that I can gain access to its content and begin learning.
Simple? Not quite.
Let’s break it down with real-world Acceptance Criteria.
✅ Acceptance Criteria (AC)
- The course must exist and be active
- The course must have available slots (i.e., not full)
- The user must have completed payment (either previously or during registration)
- The user must not already be enrolled in the course
- On successful registration:
- The user is enrolled in the course
- A confirmation is sent (email, notification, etc.)
- The system records the enrollment for reporting/analytics
Now, imagine trying to write tests — or even code — without these ACs. That’s where problems start.
🧠 The Thinking Process
Before we even think about writing a test, we have to ask questions. This is what real engineers do.
- What does “register for a course” actually mean?
- What are the business rules involved?
- What systems and data are involved?
- What needs to happen on success?
- What happens when something fails?
- Should this be atomic?
This level of reflection is often skipped especially in teams that rush forward under the banner of “agile.” But let’s be real: being agile doesn’t mean skipping analysis.
The cost of rework bugs, failed QA, UAT rejections — is high. Thinking early reduces that cost. Writing meaningful tests is a byproduct of that thinking.
🔧 The Implementation: CourseRegistrationService
Based on our user story and ACs, we implemented the following service class:
- java
- CopyEdit
- public class CourseRegistrationService {
- private final CourseRepository courseRepo;
- private final UserRepository userRepo;
- private final EnrollmentRepository enrollmentRepo;
- private final PaymentService paymentService;
- private final NotificationService notificationService;
- public CourseRegistrationService(
- CourseRepository courseRepo,
- UserRepository userRepo,
- EnrollmentRepository enrollmentRepo,
- PaymentService paymentService,
- NotificationService notificationService
- ) {
- this.courseRepo = courseRepo;
- this.userRepo = userRepo;
- this.enrollmentRepo = enrollmentRepo;
- this.paymentService = paymentService;
- this.notificationService = notificationService;
- }
- public RegistrationResult registerUserToCourse(String userId, String courseId) {
- Optional<Course> courseOpt = courseRepo.findById(courseId);
- if (courseOpt.isEmpty()) {
- return RegistrationResult.failure(“Course not found.”);
- }
- Course course = courseOpt.get();
- if (!course.isActive()) {
- return RegistrationResult.failure(“Course is not active.”);
- }
- if (course.getEnrolledCount() >= course.getCapacity()) {
- return RegistrationResult.failure(“Course is full.”);
- }
- Optional<User> userOpt = userRepo.findById(userId);
- if (userOpt.isEmpty()) {
- return RegistrationResult.failure(“User not found.”);
- }
- if (enrollmentRepo.isUserEnrolled(userId, courseId)) {
- return RegistrationResult.failure(“User already enrolled.”);
- }
- if (!paymentService.hasPaid(userId, courseId)) {
- return RegistrationResult.failure(“Payment not completed.”);
- }
- // Proceed with enrollment
- Enrollment enrollment = new Enrollment(userId, courseId);
- enrollmentRepo.save(enrollment);
- course.incrementEnrolledCount();
- notificationService.sendRegistrationConfirmation(userId, courseId);
- return RegistrationResult.success();
- }
- }
This logic directly reflects the ACs and business rules we defined.
🧪 Layers of Testing
Testing in a real application isn’t just about hitting “Run Tests” and watching for green ticks. You need to understand what layer you’re testing:
1. Unit Testing – Business Logic
This is where we test the internal logic:
- What happens if the course is full?
- What if the payment is incomplete?
- What if the user is already enrolled?
These tests are fast, isolated, and verify correctness.
2. Validation
Some of this happens earlier (e.g., in the controller or DTO), but you still want to assert that the service behaves correctly when it receives invalid or unexpected inputs.
3. Service-Level Integration Testing
Now we’re touching databases. Does the Enrollment get saved? Does enrolledCount increment?
You’ll need an actual (or in-memory) database to verify this.
4. External Integration Testing
Here, you mock/stub external systems (like NotificationService or PaymentService) and assert:
- They were called correctly
- The app behaves correctly depending on their responses