So far, we have implemented product registration, lookup, and validation.
Changing working code is always a little frightening.
What if changing this breaks the feature we just built?
TDD makes that fear easier to overcome.
The thorough tests we have written act as a
safety net
.
Today, we will add a new discount policy and learn how to extend functionality without breaking existing code.
Analyze the New Requirement ๐
The product planner has a new request.
Add a discount policy
Automatically discount products priced at 30,000 KRW or more by 1,000 KRW.
(Example: enter 35,000 KRW -> store 34,000 KRW.)
We could implement this with an
if statement in the
Service
. Since we have learned domain-centered design, however, we will let the
Product domain object
decide the discount itself.
Step 1: Write a Failing Test (Red) ๐
Our first task is to test the
Product object.
Even though the discount logic does not exist yet, we first write a test that insists the discount should be applied.
src/test/java/com/example/tdd_study/product/ProductTest.java package com.example.tdd_study.product;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class ProductTest {
@ Test
void ๊ฐ๊ฒฉ์ด_3๋ง์_์ด์์ด๋๋ฉด_1000์_ํ ์ธ๋๋ค () {
// given
String name = "๋น์ผ ์ปคํผ" ;
int originalPrice = 35000 ;
int expectedPrice = 34000 ; // 1000์ ํ ์ธ ๊ธฐ๋
System.out. println ( "[Test Start] ํ ์ธ ์ ์ฑ
ํ
์คํธ ์์" );
System.out. println ( "์
๋ ฅ ๊ฐ๊ฒฉ: " + originalPrice);
// when
Product product = new Product (name, originalPrice);
// then
System.out. println ( "๊ฒ์ฆ ๊ฒฐ๊ณผ: ์ค์ ์ ์ฅ๋ ๊ฐ๊ฒฉ = " + product. getPrice ());
assertThat (product. getPrice ()). isEqualTo (expectedPrice);
System.out. println ( "[Test Pass] ํ
์คํธ ์ฑ๊ณต! ํ ์ธ์ด ์ ์ ์ ์ฉ๋จ" );
}
@ Test
void ๊ฐ๊ฒฉ์ด_3๋ง์_๋ฏธ๋ง์ด๋ฉด_ํ ์ธ์ด_์๋ค () {
// given
int price = 20000 ;
// when
Product product = new Product ( "์ผ๋ฐ ์ปคํผ" , price);
// then
assertThat (product. getPrice ()). isEqualTo (price);
}
}
Naturally, this test
fails (Red)
.
Because we have not implemented the logic, the original price of
35000 KRW is stored unchanged.
The console prints
๊ฒ์ฆ ๊ฒฐ๊ณผ: ์ค์ ์ ์ฅ๋ ๊ฐ๊ฒฉ = 35000 (validation result: actual stored price = 35000), and the test reports that it differs from the expected value, 34000.
RED ERROR
Step 2: Implement the Feature (Green) ๐
Now we modify the
Product class to make the test pass.
Inside the constructor, we check the price and apply the discount.
src/main/java/com/example/tdd_study/product/Product.java package com.example.tdd_study.product;
import jakarta.persistence. * ;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.springframework.util.Assert;
@ Entity
@ Table ( name = "products" )
@ Getter
@ NoArgsConstructor ( access = AccessLevel.PROTECTED)
public class Product {
@ Id
@ GeneratedValue ( strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private int price;
public Product (String name , int price ) {
Assert. hasText (name, "์ํ๋ช
์ ํ์์
๋๋ค." );
Assert. isTrue (price > 0 , "์ํ ๊ฐ๊ฒฉ์ 0๋ณด๋ค ์ปค์ผ ํฉ๋๋ค." );
Assert. isTrue (price % 1000 == 0 , "์ํ ๊ฐ๊ฒฉ์ 1000์ ๋จ์์ฌ์ผ ํฉ๋๋ค." );
// [์ถ๊ฐ] ํ ์ธ ์ ์ฑ
์ ์ฉ
if (price >= 30000 ) {
System.out. println ( "[Logic] 3๋ง์ ์ด์ ๊ฐ์ง! 1000์ ํ ์ธ ์ ์ฉํจ." );
price = price - 1000 ;
}
this .name = name;
this .price = price;
}
}
Run ProductTest again.
Analyze the Execution Logs ๐
Execution logs
[Test Start] ํ ์ธ ์ ์ฑ
ํ
์คํธ ์์
์
๋ ฅ ๊ฐ๊ฒฉ: 35000
[Logic] 3๋ง์ ์ด์ ๊ฐ์ง! 1000์ ํ ์ธ ์ ์ฉํจ.
๊ฒ์ฆ ๊ฒฐ๊ณผ: ์ค์ ์ ์ฅ๋ ๊ฐ๊ฒฉ = 34000
[Test Pass] ํ
์คํธ ์ฑ๊ณต! ํ ์ธ์ด ์ ์ ์ ์ฉ๋จ
The logs clearly show the input, 35000, passing through the logic (the Logic log entry) and becoming the final value, 34000.
The test
passes (Green)
.
Step 3: Check Existing Tests (Regression Testing) ๐
We cannot stop here.
Since we changed the code, we must check
whether any existing functionality has broken
.
This is called
regression testing
.
Update the Service Test ๐
Update the integration test to reflect the new discount policy.
src/test/java/com/example/tdd_study/product/ProductServiceTest.java package com.example.tdd_study.product;
import com.example.tdd_study.notification.NotificationCenter;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.time.LocalDateTime;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.verify;
@ ExtendWith (MockitoExtension.class)
class ProductServiceTest {
@ Mock
private ProductRepository productRepository;
@ Mock // [์ค์] ์ด ๋ถ๋ถ์ด ์์ด์ NullPointerException์ด ๋ฐ์ํ์ต๋๋ค.
private NotificationCenter notificationCenter;
@ InjectMocks
private ProductService productService;
@ Test
void ์ํ_๋ฑ๋ก์_๊ณ ๊ฐ_์ํ์_ํ ์ธ๋์ด_์ ์ฅ๋๋ค () {
// given
String name = "๊ณ ๊ธ ์๊ณ" ;
int inputPrice = 40000 ; // 3๋ง์ ์ด์
LocalDateTime fixedTime = LocalDateTime. of ( 2025 , 1 , 1 , 12 , 0 );
System.out. println ( ">>> Service ํ
์คํธ ์์: " + name + ", ๊ฐ๊ฒฉ: " + inputPrice);
// when
productService. register (name, inputPrice, fixedTime);
// then
// Repository.save()๊ฐ ํธ์ถ๋ ๋, ๋๊ฒจ์ง Product ๊ฐ์ฒด๋ฅผ ๋์์ฑ๋๋ค(Capture).
ArgumentCaptor < Product > productCaptor = ArgumentCaptor. forClass (Product.class);
verify (productRepository). save (productCaptor. capture ());
// [์ถ๊ฐ] ์๋ฆผ ๋ฐ์ก๋ ๊ฒ์ฆ (์ฌ๊ธฐ์ NPE๊ฐ ๋ฌ์์)
verify (notificationCenter). send ( any (String.class));
Product savedProduct = productCaptor. getValue ();
System.out. println ( ">>> Repository์ ์ ์ฅ๋ ๊ฐ๊ฒฉ: " + savedProduct. getPrice ());
// 40000์์ด ์๋ 39000์์ด ์ ์ฅ๋์ด์ผ ์ ์ (1000์ ํ ์ธ)
assertThat (savedProduct. getPrice ()). isEqualTo ( 39000 );
System.out. println ( ">>> Service ํ
์คํธ ํต๊ณผ ์๋ฃ" );
}
// ๊ธฐ์กด ํ
์คํธ (์์
์๊ฐ ๊ด๋ จ)๋ ํจ๊ป ์ ์งํด์ผ ํฉ๋๋ค.
@ Test
void ์์
์๊ฐ_์ธ์๋_์ํ_๋ฑ๋ก์ด_๋ถ๊ฐ๋ฅํ๋ค () {
// given
String name = "์๋ฉ๋ฆฌ์นด๋
ธ" ;
int price = 4000 ;
LocalDateTime fixedTime = LocalDateTime. of ( 2025 , 1 , 1 , 1 , 0 ); // ์๋ฒฝ 1์
System.out. println ( ">>> ์์
์๊ฐ ์ธ ํ
์คํธ ์์ (์๊ฐ: " + fixedTime + ")" );
// when & then
assertThatThrownBy (() -> productService. register (name, price, fixedTime))
. isInstanceOf (IllegalStateException.class)
. hasMessage ( "์์
์๊ฐ์ด ์๋๋๋ค." );
System.out. println ( ">>> ์์ธ ๋ฐ์ ํ์ธ ์๋ฃ" );
}
}
Analyze the Execution Logs ๐
Execution results
>>> Service ํ
์คํธ ์์: ๊ณ ๊ธ ์๊ณ, ๊ฐ๊ฒฉ: 40000
[Logic] 3๋ง์ ์ด์ ๊ฐ์ง! 1000์ ํ ์ธ ์ ์ฉํจ.
>>> Repository์ ์ ์ฅ๋ ๊ฐ๊ฒฉ: 39000
>>> Service ํ
์คํธ ํต๊ณผ ์๋ฃ
These logs clearly confirm that the internal logic of
Product
also works correctly within the
Service
flow.
Today we learned how to safely add a new feature to code already in operation.
Test First (Red)
We clarified the requirements by writing tests before implementation.
Use logs
With appropriate System.out.println statements, we could see the start of each test, the logic being executed, and the validation results.
ArgumentCaptor
We used Mockito's capture feature to retrieve and verify data changed inside the service: the price of a Product.
Regression testing
After adding the feature, we ran existing tests to check for side effects.
With tests in place, even complex new requirements become less intimidating.
Next time, we will compare
the roles of unit and integration tests
and discuss which tests to write when for an efficient testing strategy.
Mockito ArgumentCaptorโ
A powerful Mockito feature for capturing and verifying arguments passed to methods.
Regression Testingโ
Explains testing activities that check whether existing features still work after software changes.