- Authors

- Name
- Youngju Kim
- @fjvbn20031
はじめに
Javaのテストエコシステムは非常に充実しています。JUnit5による単体テスト、Mockitoによるモック、WireMockによるHTTPスタブ、TestContainersによる統合テストを組み合わせることで、信頼性の高いテストスイートを構築できます。本ガイドでは各ツールの実践的な使い方をゼロから解説します。
1. JUnit5 アノテーション
基本アノテーション
JUnit5(Jupiter)では豊富なアノテーションが用意されています。
import org.junit.jupiter.api.*;
import static org.assertj.core.api.Assertions.*;
class CalculatorTest {
private Calculator calculator;
@BeforeAll
static void initAll() {
System.out.println("全テスト開始前に一度だけ実行");
}
@BeforeEach
void setUp() {
calculator = new Calculator();
}
@AfterEach
void tearDown() {
System.out.println("各テスト後に実行");
}
@AfterAll
static void tearDownAll() {
System.out.println("全テスト終了後に一度だけ実行");
}
@Test
@DisplayName("2つの正の数を足す")
void addTwoPositiveNumbers() {
int result = calculator.add(2, 3);
assertThat(result).isEqualTo(5);
}
@Test
@Disabled("実装待ち")
void skippedTest() {
// このテストはスキップされる
}
}
@ParameterizedTest
同じロジックを複数の入力でテストする際に使います。
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.*;
class ParameterizedExamples {
@ParameterizedTest
@ValueSource(ints = {1, 2, 3, 5, 8})
void positiveNumbers(int number) {
assertThat(number).isPositive();
}
@ParameterizedTest
@CsvSource({
"2, 3, 5",
"10, 20, 30",
"-1, 1, 0"
})
void additionTest(int a, int b, int expected) {
Calculator calc = new Calculator();
assertThat(calc.add(a, b)).isEqualTo(expected);
}
@ParameterizedTest
@CsvFileSource(resources = "/test-data.csv", numLinesToSkip = 1)
void csvFileTest(int a, int b, int expected) {
assertThat(new Calculator().add(a, b)).isEqualTo(expected);
}
@ParameterizedTest
@EnumSource(DayOfWeek.class)
void enumTest(DayOfWeek day) {
assertThat(day).isNotNull();
}
@ParameterizedTest
@MethodSource("provideStrings")
void methodSourceTest(String input, int expectedLength) {
assertThat(input).hasSize(expectedLength);
}
static Stream<Arguments> provideStrings() {
return Stream.of(
Arguments.of("hello", 5),
Arguments.of("world", 5),
Arguments.of("java", 4)
);
}
}
@ExtendWith と @Nested
@ExtendWith(MockitoExtension.class)
class ServiceTest {
@Mock
private UserRepository userRepository;
@InjectMocks
private UserService userService;
@Nested
@DisplayName("ユーザー検索")
class FindUser {
@Test
@DisplayName("存在するIDで検索する")
void findByExistingId() {
when(userRepository.findById(1L))
.thenReturn(Optional.of(new User(1L, "Alice")));
User user = userService.findById(1L);
assertThat(user.getName()).isEqualTo("Alice");
}
@Test
@DisplayName("存在しないIDで例外が発生する")
void findByNonExistingId() {
when(userRepository.findById(99L))
.thenReturn(Optional.empty());
assertThatThrownBy(() -> userService.findById(99L))
.isInstanceOf(UserNotFoundException.class)
.hasMessageContaining("99");
}
}
@Nested
@DisplayName("ユーザー作成")
class CreateUser {
@Test
@DisplayName("有効なデータでユーザーを作成する")
void createWithValidData() {
User newUser = new User(null, "Bob");
User savedUser = new User(2L, "Bob");
when(userRepository.save(newUser)).thenReturn(savedUser);
User result = userService.create(newUser);
assertThat(result.getId()).isEqualTo(2L);
}
}
}
クイズ1: @BeforeEach と @BeforeAll の違いは?
答え: @BeforeEach は各テストメソッドの前に毎回実行され、@BeforeAll はテストクラス全体で一度だけ(最初に)実行されます。
解説: @BeforeAll のメソッドは static である必要があります(インスタンスではなくクラスに紐づくため)。@BeforeEach は各テストごとにフレッシュな状態を保証したい場合(例: オブジェクトの再生成)に使います。@BeforeAll は重いリソース(DB接続、サーバー起動)の初期化に適しています。
2. Mockito: Mock・Spy・Captor
@Mock と @InjectMocks
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
@Mock
private OrderRepository orderRepository;
@Mock
private PaymentGateway paymentGateway;
@Mock
private EmailService emailService;
@InjectMocks
private OrderService orderService;
@Test
void processOrder_success() {
Order order = new Order(1L, 100.0, "user@example.com");
when(orderRepository.findById(1L)).thenReturn(Optional.of(order));
when(paymentGateway.charge(order.getAmount())).thenReturn(true);
when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0));
Order result = orderService.processOrder(1L);
assertThat(result.getStatus()).isEqualTo(OrderStatus.COMPLETED);
verify(emailService).sendConfirmation(eq("user@example.com"), any());
}
@Test
void processOrder_paymentFails_throwsException() {
Order order = new Order(1L, 100.0, "user@example.com");
when(orderRepository.findById(1L)).thenReturn(Optional.of(order));
when(paymentGateway.charge(anyDouble())).thenReturn(false);
assertThatThrownBy(() -> orderService.processOrder(1L))
.isInstanceOf(PaymentFailedException.class);
verify(emailService, never()).sendConfirmation(anyString(), any());
}
}
@Spy
@Spy は実際のオブジェクトをラップし、特定のメソッドだけをスタブできます。
@ExtendWith(MockitoExtension.class)
class ListSpyTest {
@Spy
private List<String> spyList = new ArrayList<>();
@Test
void spyUsesRealMethodsByDefault() {
spyList.add("hello");
spyList.add("world");
// 実際のメソッドが呼ばれる
assertThat(spyList).hasSize(2);
// 特定のメソッドだけスタブ
doReturn(100).when(spyList).size();
assertThat(spyList.size()).isEqualTo(100);
// 実際のデータはそのまま
assertThat(spyList.get(0)).isEqualTo("hello");
}
}
@Captor と ArgumentCaptor
@ExtendWith(MockitoExtension.class)
class EmailServiceTest {
@Mock
private EmailClient emailClient;
@Captor
private ArgumentCaptor<EmailMessage> emailCaptor;
@InjectMocks
private NotificationService notificationService;
@Test
void sendWelcomeEmail_capturesCorrectMessage() {
notificationService.sendWelcome("Alice", "alice@example.com");
verify(emailClient).send(emailCaptor.capture());
EmailMessage captured = emailCaptor.getValue();
assertThat(captured.getTo()).isEqualTo("alice@example.com");
assertThat(captured.getSubject()).contains("ようこそ");
assertThat(captured.getBody()).contains("Alice");
}
@Test
void sendBulkEmails_capturesAllMessages() {
List<String> recipients = List.of("a@x.com", "b@x.com", "c@x.com");
notificationService.sendBulk(recipients, "お知らせ");
verify(emailClient, times(3)).send(emailCaptor.capture());
List<EmailMessage> allMessages = emailCaptor.getAllValues();
assertThat(allMessages).hasSize(3);
assertThat(allMessages).extracting(EmailMessage::getTo)
.containsExactlyInAnyOrderElementsOf(recipients);
}
}
when/thenReturn のバリエーション
// 複数回呼ばれた場合に異なる値を返す
when(repository.findNext())
.thenReturn("first")
.thenReturn("second")
.thenThrow(new NoSuchElementException());
// 引数に基づいて動的にレスポンスを返す
when(userRepository.findById(anyLong()))
.thenAnswer(invocation -> {
Long id = invocation.getArgument(0);
return Optional.of(new User(id, "User" + id));
});
// void メソッドのスタブ
doNothing().when(emailService).send(any());
doThrow(new RuntimeException("SMTP error")).when(emailService).send(any());
doAnswer(invocation -> {
System.out.println("メール送信: " + invocation.getArgument(0));
return null;
}).when(emailService).send(any());
クイズ2: @Mock と @Spy の使い分けは?
答え: @Mock は完全なモックオブジェクトを生成し、すべてのメソッドはデフォルトで何もしない(null やデフォルト値を返す)。@Spy は実際のオブジェクトをラップし、スタブしていないメソッドは実際の実装が呼ばれます。
解説: @Mock はすべての依存関係をコントロールしたい場合に使います。@Spy は既存クラスの一部のメソッドだけをオーバーライドしてテストしたい場合(例: 一部の外部呼び出しだけをモックしたい)に有効です。@Spy は thenReturn ではなく doReturn を使う必要がある点に注意が必要です(実際のメソッドが先に呼ばれるのを防ぐため)。
3. WireMock: HTTP スタブサーバー
基本セットアップ
<!-- pom.xml -->
<dependency>
<groupId>com.github.tomakehurst</groupId>
<artifactId>wiremock-jre8</artifactId>
<version>2.35.0</version>
<scope>test</scope>
</dependency>
JUnit5 での使用
import com.github.tomakehurst.wiremock.junit5.WireMockTest;
import com.github.tomakehurst.wiremock.client.WireMock;
@WireMockTest(httpPort = 8089)
class ExternalApiClientTest {
private ExternalApiClient client;
@BeforeEach
void setUp() {
client = new ExternalApiClient("http://localhost:8089");
}
@Test
void getUser_returnsUserFromApi() {
stubFor(get(urlEqualTo("/api/users/1"))
.willReturn(aResponse()
.withStatus(200)
.withHeader("Content-Type", "application/json")
.withBody("""
{
"id": 1,
"name": "Alice",
"email": "alice@example.com"
}
""")));
User user = client.getUser(1L);
assertThat(user.getName()).isEqualTo("Alice");
assertThat(user.getEmail()).isEqualTo("alice@example.com");
verify(getRequestedFor(urlEqualTo("/api/users/1"))
.withHeader("Accept", equalTo("application/json")));
}
@Test
void getUser_serverError_throwsException() {
stubFor(get(urlPathMatching("/api/users/.*"))
.willReturn(aResponse()
.withStatus(500)
.withBody("Internal Server Error")));
assertThatThrownBy(() -> client.getUser(1L))
.isInstanceOf(ApiException.class)
.hasMessageContaining("500");
}
}
リクエストマッチング
// クエリパラメータマッチング
stubFor(get(urlPathEqualTo("/api/search"))
.withQueryParam("q", containing("java"))
.withQueryParam("page", equalTo("1"))
.willReturn(okJson("{\"results\": []}")));
// リクエストボディマッチング
stubFor(post(urlEqualTo("/api/orders"))
.withRequestBody(matchingJsonPath("$.amount", greaterThan(0)))
.withRequestBody(matchingJsonPath("$.currency", equalTo("JPY")))
.willReturn(aResponse()
.withStatus(201)
.withHeader("Location", "/api/orders/123")));
// ヘッダーマッチング
stubFor(get(urlEqualTo("/api/secure"))
.withHeader("Authorization", matching("Bearer .+"))
.willReturn(okJson("{\"data\": \"secret\"}")));
// Basic 認証
stubFor(get(urlEqualTo("/api/basic"))
.withBasicAuth("user", "password")
.willReturn(ok()));
レスポンステンプレートと遅延
// 遅延レスポンス
stubFor(get(urlEqualTo("/api/slow"))
.willReturn(aResponse()
.withStatus(200)
.withFixedDelay(2000) // 2秒遅延
.withBody("Slow response")));
// ランダム遅延
stubFor(get(urlEqualTo("/api/random-delay"))
.willReturn(aResponse()
.withStatus(200)
.withUniformRandomDelay(500, 2000)));
// シナリオを使ったステートフルスタブ
stubFor(get(urlEqualTo("/api/status"))
.inScenario("Order Processing")
.whenScenarioStateIs(Scenario.STARTED)
.willReturn(okJson("{\"status\": \"PENDING\"}"))
.willSetStateTo("Processing"));
stubFor(get(urlEqualTo("/api/status"))
.inScenario("Order Processing")
.whenScenarioStateIs("Processing")
.willReturn(okJson("{\"status\": \"COMPLETED\"}"))
.willSetStateTo(Scenario.STARTED));
クイズ3: WireMock の stubFor と verify の役割は?
答え: stubFor は HTTP リクエストに対するレスポンスを事前に定義します。verify はテスト実行後に特定のリクエストが期待通りに送られたかを検証します。
解説: stubFor はテストの「Arrange(準備)」フェーズで使い、モックサーバーの振る舞いを設定します。verify は「Assert(検証)」フェーズで使い、実際にどのようなリクエストが送信されたかを確認します。例えば verify(postRequestedFor(urlEqualTo("/api/orders")).withRequestBody(containing("amount"))) のように使います。
4. TestContainers: コンテナを使った統合テスト
PostgreSQL テスト
@Testcontainers
@SpringBootTest
class UserRepositoryIntegrationTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15-alpine")
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test");
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
@Autowired
private UserRepository userRepository;
@Test
void saveAndFindUser() {
User user = new User(null, "Alice", "alice@example.com");
User saved = userRepository.save(user);
assertThat(saved.getId()).isNotNull();
Optional<User> found = userRepository.findById(saved.getId());
assertThat(found).isPresent();
assertThat(found.get().getEmail()).isEqualTo("alice@example.com");
}
}
Redis と Kafka のテスト
@Testcontainers
class CacheAndMessagingTest {
@Container
static GenericContainer<?> redis = new GenericContainer<>("redis:7-alpine")
.withExposedPorts(6379);
@Container
static KafkaContainer kafka = new KafkaContainer(
DockerImageName.parse("confluentinc/cp-kafka:7.4.0"));
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.data.redis.host", redis::getHost);
registry.add("spring.data.redis.port", () -> redis.getMappedPort(6379));
registry.add("spring.kafka.bootstrap-servers", kafka::getBootstrapServers);
}
@Autowired
private CacheService cacheService;
@Test
void cacheStoresAndRetrievesValue() {
cacheService.put("key1", "value1");
String retrieved = cacheService.get("key1");
assertThat(retrieved).isEqualTo("value1");
}
@Test
void kafkaProducesAndConsumesMessage() throws Exception {
kafkaProducer.send("test-topic", "Hello Kafka");
await().atMost(10, SECONDS).untilAsserted(() -> {
assertThat(testConsumer.getMessages()).contains("Hello Kafka");
});
}
}
5. Spring Boot テスト
@SpringBootTest
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class FullIntegrationTest {
@Autowired
private TestRestTemplate restTemplate;
@Test
void healthEndpointReturnsOk() {
ResponseEntity<String> response = restTemplate.getForEntity("/actuator/health", String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
}
}
@WebMvcTest と MockMvc
@WebMvcTest(UserController.class)
class UserControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private UserService userService;
@Autowired
private ObjectMapper objectMapper;
@Test
void getUser_returnsUserJson() throws Exception {
when(userService.findById(1L))
.thenReturn(new User(1L, "Alice", "alice@example.com"));
mockMvc.perform(get("/api/users/1")
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(1))
.andExpect(jsonPath("$.name").value("Alice"))
.andExpect(jsonPath("$.email").value("alice@example.com"));
}
@Test
void createUser_validInput_returns201() throws Exception {
User input = new User(null, "Bob", "bob@example.com");
User saved = new User(2L, "Bob", "bob@example.com");
when(userService.create(any(User.class))).thenReturn(saved);
mockMvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(input)))
.andExpect(status().isCreated())
.andExpect(header().string("Location", containsString("/api/users/2")));
}
@Test
void createUser_invalidEmail_returns400() throws Exception {
String invalidJson = "{\"name\": \"Bob\", \"email\": \"not-an-email\"}";
mockMvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content(invalidJson))
.andExpect(status().isBadRequest());
}
}
@DataJpaTest
@DataJpaTest
class ProductRepositoryTest {
@Autowired
private ProductRepository productRepository;
@Autowired
private TestEntityManager entityManager;
@Test
void findByCategory_returnsMatchingProducts() {
entityManager.persist(new Product(null, "Java入門", "BOOK", 3000));
entityManager.persist(new Product(null, "Spring解説", "BOOK", 4000));
entityManager.persist(new Product(null, "キーボード", "ELECTRONICS", 15000));
entityManager.flush();
List<Product> books = productRepository.findByCategory("BOOK");
assertThat(books).hasSize(2);
assertThat(books).extracting(Product::getName)
.containsExactlyInAnyOrder("Java入門", "Spring解説");
}
}
クイズ4: @WebMvcTest と @SpringBootTest の違いは?
答え: @WebMvcTest は Web レイヤー(コントローラー)のみをロードし、サービスやリポジトリはモックします。@SpringBootTest はアプリケーション全体のコンテキストをロードします。
解説: @WebMvcTest はコントローラーのルーティング・バリデーション・シリアライゼーションをテストするのに最適で、起動が速いです。@SpringBootTest は全コンポーネントの統合テストに使いますが、起動に時間がかかります。コントローラーのロジックだけをテストしたい場合は @WebMvcTest、エンドツーエンドのフローをテストしたい場合は @SpringBootTest を選びます。
6. TDD ワークフロー: Red → Green → Refactor
TDD の基本サイクル
// ステップ1: Red - 失敗するテストを書く
class ShoppingCartTest {
@Test
void emptyCart_totalIsZero() {
ShoppingCart cart = new ShoppingCart();
assertThat(cart.getTotal()).isEqualTo(0.0);
}
@Test
void addItem_increasesTotal() {
ShoppingCart cart = new ShoppingCart();
cart.addItem(new Item("Book", 1500.0));
assertThat(cart.getTotal()).isEqualTo(1500.0);
}
@Test
void addMultipleItems_sumIsCorrect() {
ShoppingCart cart = new ShoppingCart();
cart.addItem(new Item("Book", 1500.0));
cart.addItem(new Item("Pen", 200.0));
assertThat(cart.getTotal()).isEqualTo(1700.0);
}
@Test
void applyDiscount_reducesTotal() {
ShoppingCart cart = new ShoppingCart();
cart.addItem(new Item("Book", 1500.0));
cart.applyDiscount(10); // 10%割引
assertThat(cart.getTotal()).isEqualTo(1350.0);
}
}
// ステップ2: Green - テストを通過する最小限の実装
public class ShoppingCart {
private List<Item> items = new ArrayList<>();
private double discountPercent = 0;
public void addItem(Item item) {
items.add(item);
}
public void applyDiscount(double percent) {
this.discountPercent = percent;
}
public double getTotal() {
double subtotal = items.stream()
.mapToDouble(Item::getPrice)
.sum();
return subtotal * (1 - discountPercent / 100);
}
}
// ステップ3: Refactor - コードを改善する
public class ShoppingCart {
private final List<Item> items = new ArrayList<>();
private double discountPercent = 0;
public void addItem(Item item) {
Objects.requireNonNull(item, "Item must not be null");
items.add(item);
}
public void applyDiscount(double percent) {
if (percent < 0 || percent > 100) {
throw new IllegalArgumentException("Discount must be between 0 and 100");
}
this.discountPercent = percent;
}
public double getTotal() {
double subtotal = calculateSubtotal();
return applyDiscountTo(subtotal);
}
private double calculateSubtotal() {
return items.stream().mapToDouble(Item::getPrice).sum();
}
private double applyDiscountTo(double amount) {
return amount * (1 - discountPercent / 100);
}
}
7. コードカバレッジ: JaCoCo と Sonar
JaCoCo 設定
<!-- pom.xml -->
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.10</version>
<executions>
<execution>
<goals>
<goal>prepare-agent</goal>
</goals>
</execution>
<execution>
<id>report</id>
<phase>test</phase>
<goals>
<goal>report</goal>
</goals>
</execution>
<execution>
<id>check</id>
<goals>
<goal>check</goal>
</goals>
<configuration>
<rules>
<rule>
<element>BUNDLE</element>
<limits>
<limit>
<counter>LINE</counter>
<value>COVEREDRATIO</value>
<minimum>0.80</minimum>
</limit>
<limit>
<counter>BRANCH</counter>
<value>COVEREDRATIO</value>
<minimum>0.70</minimum>
</limit>
</limits>
</rule>
</rules>
</configuration>
</execution>
</executions>
</plugin>
Sonar 統合
# sonar-project.properties
sonar.projectKey=my-java-project
sonar.projectName=My Java Project
sonar.sources=src/main/java
sonar.tests=src/test/java
sonar.java.coveragePlugin=jacoco
sonar.coverage.jacoco.xmlReportPaths=target/site/jacoco/jacoco.xml
sonar.exclusions=**/generated/**,**/dto/**
# GitHub Actions での実行例
- name: Build and analyze
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
run: mvn verify sonar:sonar -Dsonar.host.url=https://sonarcloud.io
クイズ5: ラインカバレッジとブランチカバレッジの違いは?
答え: ラインカバレッジはテストで実行されたコード行の割合を示します。ブランチカバレッジは if/else・switch などの分岐をどれだけカバーしているかを示します。
解説: 例えば if (x > 0) { doA(); } else { doB(); } というコードがあります。x = 1 のみでテストした場合、ラインカバレッジは 100% でも、ブランチカバレッジは 50% です(else ブランチが未テスト)。一般的にブランチカバレッジの方が品質の高い指標とされます。JaCoCo ではラインカバレッジ 80%・ブランチカバレッジ 70% 以上を目安にすることが多いです。
8. テストのベストプラクティス
AAA パターン (Arrange-Act-Assert)
@Test
void calculateOrderTotal_withDiscountCode_appliesDiscount() {
// Arrange
Order order = new Order();
order.addItem(new Item("Book", 2000));
order.addItem(new Item("Pen", 500));
DiscountCode code = new DiscountCode("SAVE10", 10);
// Act
double total = orderService.calculateTotal(order, code);
// Assert
assertThat(total).isEqualTo(2250.0); // 2500 * 0.9
}
テストの命名規則
// methodName_stateUnderTest_expectedBehavior パターン
@Test
void getUserById_existingId_returnsUser() { }
@Test
void getUserById_nonExistingId_throwsNotFoundException() { }
@Test
void processPayment_insufficientFunds_returnsFailure() { }
// given_when_then パターン
@Test
@DisplayName("有効なクレジットカードで支払いが成功する")
void givenValidCard_whenProcessPayment_thenSucceeds() { }
クイズ6: テストピラミッドとは何か?
答え: テストピラミッドとは、下から「単体テスト(多)→ 統合テスト(中)→ E2E テスト(少)」という形で構成される理想的なテスト戦略の概念です。
解説: 単体テストは高速・安定・安価なのでたくさん書きます。統合テストは外部サービスや DB を含むため適度な数にします。E2E テスト(ブラウザテスト等)は実行に時間がかかりフラッキーになりやすいので最小限にします。この逆三角形(E2E が多い状態)を「アイスクリームコーン」と呼び、アンチパターンとされています。
まとめ
| ツール | 役割 | 主なアノテーション |
|---|---|---|
| JUnit5 | テストフレームワーク | @Test, @ParameterizedTest, @Nested |
| Mockito | モック/スタブ | @Mock, @Spy, @InjectMocks, @Captor |
| WireMock | HTTP スタブ | stubFor, verify |
| TestContainers | 統合テスト | @Container, @Testcontainers |
| Spring Boot Test | Web レイヤーテスト | @WebMvcTest, @DataJpaTest |
| JaCoCo | カバレッジ計測 | Maven Plugin |
適切なテスト戦略を構築することで、リグレッションを防ぎ、リファクタリングへの自信を持つことができます。TDD を実践し、Red-Green-Refactor のサイクルを意識してコードを書きましょう。