- 韓国人として、日本語とコンピュータの勉強を同時に行うために、ここに文章を書いています
- 翻訳ツールの助けを借りて書いた文章なので、誤りがあるかもしれません
はじめに
従来は主にユニットテストのみを作成してサービスの安定性を確保してきました。しかし、最近の忙しいスケジュールによりテストカバレッジが大幅に低下しています。
サービスのドメインが徐々に拡大するにつれて、ユーザー全体の流れをより正確に検証し、体系的に管理する必要性が高まっています。
さらに、サービスの拡大に伴いドメインコードも複雑化しており、これらのコードがクライアント側に直接的な影響を与えないよう、リファクタリングを通じて変化に柔軟なコードを準備する時期だと判断しました。
今後も引き続き拡大するサービスにおいて安定性と柔軟性を確保するために、E2Eテストの導入を通じてAPIの検証とコード改善を並行して進める必要があると考えました。
そしてもう一つ、E2Eテストを導入する最大の理由があります。
私はSpring Bootベースのアプリケーションを運用しており、さまざまなデータベースやキャッシングソリューションを組み合わせて使用しています。具体的には、
MySQLはRDBMSとして主要なデータ管理を行い、
MongoDBは地域検索結果のキャッシュを処理し、
Elasticsearchは検索結果のキャッシュを担当し、
Redisはその他さまざまなキャッシング用途に活用されています。
このように複数のストレージシステムを使用する複雑な構造では、各階層間の相互作用を正確に予測し、検証するのは簡単ではありません。したがって、E2Eテストを通じてこれらの階層間の相互作用も検証し、私のソフトウェアに対する信頼性を高めるつもりです。
1回目、Spring Securityの認証と権限処理をどうするか。
一つ目の方法
以前のプロジェクトで@ WithMockCustomUserアノテーションを定義して、Authenticationにモックオブジェクトを渡してテストを行った方法が最優先で思い浮かびました。
@Retention(RetentionPolicy.RUNTIME)
@WithSecurityContext(factory = WithMockCustomUserSecurityContextFactory.class)
public @interface WithMockCustomUser {
String username() default "test";
String role() default "USER";
}
public class WithMockCustomUserSecurityContextFactory
implements WithSecurityContextFactory<WithMockCustomUser> {
@Override
public SecurityContext createSecurityContext(WithMockCustomUser customUser) {
SecurityContext context = SecurityContextHolder.createEmptyContext();
CustomUserDetails principal = new CustomUserDetails(customUser.username());
List<GrantedAuthority> authorities = List.of(new SimpleGrantedAuthority("ROLE_" + customUser.role()));
Authentication auth =
UsernamePasswordAuthenticationToken.authenticated(principal, "password", authorities);
context.setAuthentication(auth);
return context;
}
}
この方法は以前から知っている方法で、適用が簡単なのでこの方法で進めましたが、この方法では私がE2Eテストを導入した背景を色あせさせるのではないかという考えが次第に浮かんできました。
WHY?
- E2Eテストの本質であるユーザーの実際の流れを検証できないという問題が最も大きく感じられました.
確かに、私のAPIの不変条件では「特定の権限を持つユーザーが特定のサービスを利用できる」ということがあり、それを検証するためにフィルターを作成してアクセストークンや有効期限、ユーザー権限を確認するのは、確かに私のサービスロジックです。
しかし、すべての認証と権限処理の段階を飛ばしてしまうということは、E2Eテストの本質である「ユーザーの実際の流れを検証」できないという考えが強くなり、この方法を諦めました。
また、私はE2Eテストはできるだけ実際のデプロイ環境に近い形で構築されるべきだと考えています。しかし、E2Eテストでこのようにモック方式を使用するということは、実際のデプロイ環境との違いを意味するため、このテストコードで私がコードに対する信頼性を確保できるのか? という質問には否定的に答えざるを得ませんでした。
そのため、別の方法を考えました。
二つ目の方法
私のサービスはOAuthログインを基にJWTトークンを発行して、認証および権限確認の手続きを行います。
この流れの中で、現在テストに使用しているロジックであるJWTトークンを直接作成して挿入する方法を採用することにしました。
@Autowired
private TokenProvider tokenProvider;
public TokenResponse createAndLoadTokenResponse(MemberAuthentication memberAuthentication) {
return tokenProvider.createToken(memberAuthentication);
}
String result = mockMvc
.perform(
post("/graphql")
.header("Authorization", "Bearer " + tokenResponse.accessToken())
.contentType(MediaType.APPLICATION_JSON)
.content(requestJson))
.andDo(print())
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8);
次のように実際のアクセストークンを挿入することで、テスト環境と実際の環境をできるだけ一致させ、私のコードに対する信頼度を高めることができました。
実際のログインロジック(OAuthやJWT発行手順)を検証しなかった理由は、他のドメインテストでその部分を検証するため、重複になるからです。また、1つのテストでは1つのテストだけを行うことがOOPの単一責任原則に合致し、テストコードのドキュメント化にも役立つと考えています。
2回目, トランザクションテスト時のNOSQLデータベースのデータ整理
現在、私が作成したテストはトランザクションテストを行っています。これにより、パフォーマンスの向上、独立性、一貫性を保証していますが、このロールバックメカニズムがMongoDBやRedisのようなNoSQLデータベースでは動作しないという問題があります。
私には大きく以下の選択肢がありました。
- トランザクションを実装する方法を探していたところ、MongoDBの最新バージョンからトランザクションが使用できることを知りました。そのため、トランザクションを使用する方法.
-
NoSQLの値はキャッシュデータとして使われ、キーの役割が識別子に限定され、値だけを検証すればよいので、単純にロールバックをしない方法
-
リポジトリのクエリを送信するところまでを検証し、返却データはモックする方法
-
MongoDBとRedisのデータを手動で削除および初期化する方法
- 私はこの中で、MongoDBとRedisのデータを手動で削除および初期化する方法を選びました
WHY?
削除によるパフォーマンス低下はメモリベースのデータベースではそれほど大きくなく、1つのテストあたりのデータ量も多くないと考えました。
また、この方法が最も簡単で、確実に一貫性を維持できる方法だと思いました。
もし、この方法で有意なテスト速度の低下や一貫性の問題が発生する場合は、その時に分析すればよいと考えました。
@AfterEach
void cleanupAfterTest() {
mongoTemplate.getCollectionNames().forEach(collectionName -> {
mongoTemplate.dropCollection(collectionName);
});
RedisServerCommands serverCommands = Objects.requireNonNull(redisTemplate.getConnectionFactory())
.getConnection()
.serverCommands();
serverCommands.flushAll();
}
3回目, データ作成
従来、ユニットテストを行う際には、テストオブジェクトを直接定義したり、FixtureMonkeyを使用してオブジェクトを生成していました。しかし、E2Eテストでは実際のデータベースにデータを投入する必要があり、DBデータの制約事項や@ Entityの条件、関連オブジェクトも一緒にロードする必要があるため、FixtureMonkey方式には限界がありました。
また、Serviceレイヤーを通じて必要なオブジェクトを生成する方法も考慮しましたが、この方法はボイラープレートコードが増え、サービスコードとテストコード間の結合度が高くなるという欠点がありました。
これらの問題を解決するために、Fixtureクラスを定義して、Repositoryのビーンを通じてデータを直接管理し、必要なテストオブジェクトを生成する方法を導入しました。この方法は、重複コードを減らし、テストオブジェクト生成の複雑さを管理し、E2Eテストの制約をより効果的に解決することができました。
/*
repository 의존성을 넣는 abstract class 입니다
ApplicationContext, TokenProvider를 함께 받습니다.
*/
@Profile("test")
@Configuration
@ComponentScan(basePackages = "com.hubo.gillajabi",
useDefaultFilters = false,
includeFilters = @ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, value = FixtureBase.class))
public abstract class FixtureBase {
@Autowired
protected ApplicationContext context;
@Autowired
private TokenProvider tokenProvider;
protected <T extends JpaRepository<?, ?>> T getRepository(Class<T> repositoryClass) {
return context.getBean(repositoryClass);
}
public MemberAuthentication createAndLoadMember(String username, RoleStatus roleStatus) {
MemberRepository memberRepository = getRepository(MemberRepository.class);
Member member = Member.builder()
.nickName(username)
.profileImageUrl("profile.com")
.lastLoginAt(LocalDateTime.now())
.build();
MemberAuthentication memberAuthentication = MemberAuthentication.builder()
.member(member)
.roleStatus(roleStatus)
.build();
memberRepository.save(member);
return memberAuthentication;
}
public TokenResponse createAndLoadTokenResponse(MemberAuthentication memberAuthentication) {
TokenProvider tokenProvider = context.getBean(TokenProvider.class);
return tokenProvider.createToken(memberAuthentication);
}
}
@Component
@Profile("test")
public class UserPointFixture extends FixtureBase {
@Autowired
private UserPointDocumentRepository userPointDocumentRepository;
public Course createAndLoadCourse() {
CourseRepository courseRepository = getRepository(CourseRepository.class);
String randomOriginName = UUID.randomUUID().toString().substring(0, 10);
City city = createAndLoadCity();
Course course = Course.builder()
.originName(randomOriginName)
.city(city)
.courseNumber(generateUniqueCourseNumber())
.shortDescription("shortDescription")
.build();
return courseRepository.save(course);
}
4回目, CIテスト環境統一
現在、CI時にテストはGitHub Actionsのワークフローを使用して実行しています
E2Eテストを導入した現在、MySQL、Redis、Elasticsearch、MongoDBを一緒に実行する必要があるため、GitHub Actionsで設定を行いました。
jobs:
test:
runs-on: ubuntu-latest
services:
mysql:
image: mysql:8.0.33
env:
MYSQL_DATABASE: ${{ secrets.MYSQL_DATABASE }}
MYSQL_ROOT_HOST: '%'
MYSQL_ROOT_PASSWORD: ${{ secrets.MYSQL_ROOT_PASSWORD }}
TZ: Asia/Seoul
MYSQL_CHARACTER_SET_SERVER: utf8mb4
MYSQL_COLLATION_SERVER: utf8mb4_unicode_ci
ports:
- 3306:3306
options: >-
--health-cmd="mysqladmin ping"
--health-interval=10s
--health-timeout=5s
--health-retries=3
redis:
image: redis:latest
ports:
- 6379:6379
elasticsearch:
image: docker.elastic.co/elasticsearch/elasticsearch:8.5.3
env:
discovery.type: single-node
xpack.security.enabled: true
ELASTIC_PASSWORD: ${{ secrets.MYSQL_ROOT_PASSWORD }}
xpack.security.authc.api_key.enabled: true
xpack.security.transport.ssl.enabled: false
ports:
- 9200:9200
mongodb:
image: mongo:latest
env:
MONGO_INITDB_ROOT_USERNAME: ${{ secrets.MONGO_ROOT_USERNAME }}
MONGO_INITDB_ROOT_PASSWORD: ${{ secrets.MONGO_ROOT_PASSWORD }}
ports:
- 27017:27017
options: >-
--health-cmd "mongosh --eval 'db.runCommand({ping:1})'"
--health-interval 10s
--health-timeout 5s
--health-retries 5
しかし、問題が発生しました。ローカル環境では正常に通過していたテストが、GitHub Actionsの環境では失敗するという問題でした
私は2つのことを疑いました。
- ローカルでは通過するのに、GitHub Actionsでは通過しない場合、90%以上の確率でこれは環境の問題です
- @ SpringBootTestだけでテストが失敗するのを見て、Beanの注入が失敗していると思われます
Elasticsearchが正しく起動しているかを検証するステップを作成し、依存性注入が正しく行われているか確認するコードを追加して、テストを再度実行しました。
steps:
- name: Wait for Elasticsearch
run: |
timeout 600 bash -c '
while ! curl -s -u elastic:${{ secrets.MYSQL_ROOT_PASSWORD }} -o /dev/null -w "%{http_code}" http://127.0.0.1:9200 | grep -q "200"; do
echo "Waiting for Elasticsearch..."
sleep 5
done
'
- name: Verify Elasticsearch Health
run: |
curl -u elastic:${{ secrets.MYSQL_ROOT_PASSWORD }} http://127.0.0.1:9200/_cluster/health
@SpringBootTest
@ActiveProfiles("test")
class HuboApplicationTests {
private static final Logger logger = LoggerFactory.getLogger(HuboApplicationTests.class);
@Autowired
private ElasticsearchClient elasticsearchClient;
@Test
public void testElasticsearchConnection() {
logger.info("Starting Elasticsearch connection test");
try {
var response = elasticsearchClient.info();
logger.info("Elasticsearch info: {}", response);
} catch (Exception e) {
logger.error("Error connecting to Elasticsearch", e);
throw new RuntimeException("Failed to connect to Elasticsearch", e);
}
}
Elasticsearchの接続とBeanが正しく注入されていることを確認し、ログレベルを調整してより詳細な分析を行いました。
Caused by: co.elastic.clients.json.JsonpMappingException: Error deserializing co.elastic.clients.elasticsearch._types.mapping.NestedProperty: Unknown field 'index' (JSON path: properties.content.index) (in object at line no=1, column no=1063, offset=1062)
次のようなエラーを見つけ、それを基に原因を特定することに成功しました。
@ Field(type = FieldType.Nested, index = false) Elasticsearchのドキュメントモデリングにおいて、特定のフィールドを「ネスト(nested)」形式で保存する際にNestedを使用しますが、通常のStringフィールドを保存する際にも誤ってNestedを適用してしまいました。(以前は正しく記述し、追加のフィールドを定義する際にミスをしたため)ローカル環境のElasticsearchコンテナでは以前の設定が適用されていたため、問題が発生しませんでした。
ドッカーボリュームを完全に削除し、コンテナを再起動してテストを行ったところ、そのエラーをローカル環境でも再現することができました。
@DisplayName("기준점으로 조회시 근처에 해당 코스의 유저 포인트의 데이터가 있는경우")
@Test
void 기준점_중심으로_조회시_해당_코스의_유저_포인트가_있는경우() throws Exception {
// given
MemberAuthentication memberAuthentication = userPointFixture.createAndLoadMember("testUser", RoleStatus.USER);
TokenResponse tokenResponse = userPointFixture.createAndLoadTokenResponse(memberAuthentication);
Course course = userPointFixture.createAndLoadCourse();
UserPointDocument userPointDocument = userPointFixture.createUserPointDocument(memberAuthentication.getMember(), course);
userPointFixture.createCourseBookMark(memberAuthentication.getMember(), course);
// when
String requestJson = String.format("""
{
"query": "query {
getUsedPointPreviewsByCourse(courseId: %d, latitude: 37.123456, longitude: 127.123456, radius: 100.00){
userPointPreviews {
id
content
userPointId
course {
id
name
}
longitude
latitude
imageUrl
member {
id
nickname
profile
}
}
pageInfo {
nextCursor
hasNextPage
}
}
}"
}
""", course.getId()).replace("\n", "").replace("\r", "");
String result = mockMvc
.perform(
post("/graphql")
.header("Authorization", "Bearer " + tokenResponse.accessToken())
.contentType(MediaType.APPLICATION_JSON)
.content(requestJson))
.andDo(print())
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8);
// then
JsonNode rootNode = objectMapper.readTree(result);
Long userPointId = rootNode.path("data")
.path("getUsedPointPreviewsByCourse")
.path("userPointPreviews")
.get(0)
.path("userPointId")
.asLong();
assertThat(userPointId).isEqualTo(userPointDocument.getUserPointId());
}
これからも楽しくテストコードを作成できそうです。