Testcontainers + Elasticsearch 实战——搜索功能集成测试完整方案
Testcontainers + Elasticsearch 实战——搜索功能集成测试完整方案
适读人群:Java 后端开发者、搜索系统开发者 | 阅读时长:约 16 分钟 | 核心价值:用真实 ES 测试搜索逻辑,避免 Mock 掩盖的分词、相关性和 mapping 问题
做电商搜索的时候,我们团队经历过一次让人哭笑不得的线上事故。
需求很简单:用户搜索"苹果手机",应该能搜到 iPhone 相关商品。这个功能在本地测了,QA 测了,上线前的冒烟测试也通过了。结果上线第一天,用户反馈:搜"苹果手机"搜不到任何商品。
查了两个小时,发现问题在 mapping。我们新建了一个索引,商品名称字段用的是 ik_smart 分词器,但由于 ES 集群里没有安装 ik_max_word 插件(只装了 ik_smart),创建索引时没报错(因为我们只用了 ik_smart),但搜索时我们的 query_string 查询会自动尝试用 ik_max_word 分析,找不到分词器就 fallback 到 standard,导致中文分词完全失效。
在本地测试的时候,同事的 ES 装了完整的 ik 插件,测试通过。CI 环境用的是 Mock,完全绕过了 ES。生产环境的 ES 只装了部分插件,问题暴露了。
那次之后,搜索相关的代码,我们必须用真实 ES 容器跑集成测试,且测试用的 ES 镜像和生产配置保持一致,包括安装哪些插件。
一、Elasticsearch 集成测试的特殊挑战
ES 集成测试比一般数据库测试更复杂:
- 分词器差异:不同版本的 ES,同一个分词器的行为可能不同
- mapping 兼容性:mapping 定义一旦创建就不能修改字段类型,测试要验证 mapping 正确
- 相关性评分:搜索结果的排序涉及 BM25 算法,Mock 无法模拟
- 异步刷新:ES 默认 1 秒刷新间隔,写入后不一定立即可查
二、依赖配置
<dependencies>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers</artifactId>
<version>1.19.3</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<version>1.19.3</version>
<scope>test</scope>
</dependency>
<!-- ES 专用模块 -->
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>elasticsearch</artifactId>
<version>1.19.3</version>
<scope>test</scope>
</dependency>
<!-- Spring Data Elasticsearch -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-elasticsearch</artifactId>
</dependency>
<dependency>
<groupId>org.awaitility</groupId>
<artifactId>awaitility</artifactId>
<scope>test</scope>
</dependency>
</dependencies>三、ES 容器配置
基础配置
@SpringBootTest
@Testcontainers
class ElasticsearchBaseTest {
@Container
static ElasticsearchContainer elasticsearch = new ElasticsearchContainer(
DockerImageName.parse("docker.elastic.co/elasticsearch/elasticsearch:8.11.1"))
.withEnv("discovery.type", "single-node")
.withEnv("ES_JAVA_OPTS", "-Xms512m -Xmx512m")
.withEnv("xpack.security.enabled", "false") // 关闭安全认证,简化测试
.withStartupTimeout(Duration.ofMinutes(3));
@DynamicPropertySource
static void configureElasticsearch(DynamicPropertyRegistry registry) {
registry.add("spring.elasticsearch.uris", elasticsearch::getHttpHostAddress);
// 如果有密码的话
// registry.add("spring.elasticsearch.username", () -> "elastic");
// registry.add("spring.elasticsearch.password", elasticsearch::getPassword);
}
}带 IK 分词器的配置(中文搜索必须)
@Container
static ElasticsearchContainer elasticsearch = new ElasticsearchContainer(
// 使用预装了 IK 分词器的自定义镜像
DockerImageName.parse("your-registry/elasticsearch-ik:8.11.1"))
.withEnv("discovery.type", "single-node")
.withEnv("ES_JAVA_OPTS", "-Xms512m -Xmx512m")
.withEnv("xpack.security.enabled", "false");
// 或者在容器启动后安装插件(较慢,但不需要自定义镜像)
// 不推荐在 CI 里这样做,太慢四、完整的商品搜索集成测试
商品索引定义:
@Document(indexName = "products")
@Setting(settingPath = "es/product-settings.json")
@Mapping(mappingPath = "es/product-mapping.json")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ProductDocument {
@Id
private String id;
@Field(type = FieldType.Text, analyzer = "ik_smart", searchAnalyzer = "ik_smart")
private String name;
@Field(type = FieldType.Text, analyzer = "ik_smart")
private String description;
@Field(type = FieldType.Keyword)
private String category;
@Field(type = FieldType.Double)
private Double price;
@Field(type = FieldType.Integer)
private Integer stock;
@Field(type = FieldType.Boolean)
private Boolean available;
@Field(type = FieldType.Date)
private Date createdAt;
}搜索服务:
@Service
@RequiredArgsConstructor
public class ProductSearchService {
private final ElasticsearchOperations operations;
public SearchResult<ProductDocument> search(String keyword, int page, int size) {
Query query = NativeQuery.builder()
.withQuery(q -> q
.bool(b -> b
.must(m -> m
.multiMatch(mm -> mm
.query(keyword)
.fields("name^3", "description") // name 权重更高
.type(TextQueryType.BestFields)
)
)
.filter(f -> f.term(t -> t.field("available").value(true)))
)
)
.withPageable(PageRequest.of(page, size))
.build();
SearchHits<ProductDocument> hits = operations.search(query, ProductDocument.class);
return SearchResult.of(hits);
}
public List<String> suggest(String prefix) {
Query query = NativeQuery.builder()
.withQuery(q -> q
.matchPhrasePrefix(mp -> mp
.field("name")
.query(prefix)
)
)
.withMaxResults(5)
.build();
return operations.search(query, ProductDocument.class)
.stream()
.map(hit -> hit.getContent().getName())
.collect(Collectors.toList());
}
}集成测试:
@SpringBootTest
@Testcontainers
class ProductSearchServiceIntegrationTest {
@Container
static ElasticsearchContainer elasticsearch = new ElasticsearchContainer(
DockerImageName.parse("docker.elastic.co/elasticsearch/elasticsearch:8.11.1"))
.withEnv("discovery.type", "single-node")
.withEnv("ES_JAVA_OPTS", "-Xms512m -Xmx512m")
.withEnv("xpack.security.enabled", "false");
@DynamicPropertySource
static void configureEs(DynamicPropertyRegistry registry) {
registry.add("spring.elasticsearch.uris", elasticsearch::getHttpHostAddress);
}
@Autowired
private ProductSearchService searchService;
@Autowired
private ProductRepository productRepository;
@BeforeEach
void setUp() {
productRepository.deleteAll();
}
@Test
void 关键词搜索_精确匹配_返回相关商品() {
// given
List<ProductDocument> products = List.of(
ProductDocument.builder()
.id("1")
.name("Apple iPhone 15 Pro 手机")
.description("苹果最新旗舰手机")
.category("手机")
.price(8999.0)
.available(true)
.build(),
ProductDocument.builder()
.id("2")
.name("华为 Mate 60 Pro 手机")
.description("华为旗舰手机")
.category("手机")
.price(6999.0)
.available(true)
.build(),
ProductDocument.builder()
.id("3")
.name("苹果笔记本 MacBook Pro")
.description("苹果笔记本电脑")
.category("电脑")
.price(12999.0)
.available(true)
.build()
);
productRepository.saveAll(products);
// ES 需要刷新才能搜索到刚写入的数据
refreshIndex();
// when
SearchResult<ProductDocument> result = searchService.search("iPhone 手机", 0, 10);
// then
assertThat(result.getTotalHits()).isGreaterThan(0);
// iPhone 15 应该排在最前面(name 字段权重 3 倍)
assertThat(result.getContent().get(0).getName()).contains("iPhone");
}
@Test
void 搜索_下架商品_不出现在结果中() {
// given
ProductDocument offlineProduct = ProductDocument.builder()
.id("10")
.name("下架的手机")
.available(false) // 已下架
.price(100.0)
.build();
productRepository.save(offlineProduct);
refreshIndex();
// when
SearchResult<ProductDocument> result = searchService.search("手机", 0, 10);
// then
boolean hasOfflineProduct = result.getContent().stream()
.anyMatch(p -> p.getId().equals("10"));
assertThat(hasOfflineProduct).isFalse();
}
@Test
void 搜索建议_前缀匹配_返回候选词() {
// given
productRepository.saveAll(List.of(
ProductDocument.builder().id("20").name("苹果手机 iPhone").available(true).price(100.0).build(),
ProductDocument.builder().id("21").name("苹果平板 iPad").available(true).price(200.0).build(),
ProductDocument.builder().id("22").name("苹果耳机 AirPods").available(true).price(300.0).build()
));
refreshIndex();
// when
List<String> suggestions = searchService.suggest("苹果");
// then
assertThat(suggestions).isNotEmpty();
assertThat(suggestions).allMatch(s -> s.contains("苹果"));
}
private void refreshIndex() {
// 强制刷新 ES 索引,不等 1 秒自动刷新
operations.indexOps(ProductDocument.class).refresh();
}
}五、三个踩坑实录
坑 1:写入后立即查询返回空结果
现象: save() 之后立刻调用 search(),返回空结果集。
原因: Elasticsearch 默认的刷新间隔(refresh interval)是 1 秒,写入的数据在 refresh 之前对搜索不可见。
解法:
// 方式一:写入后手动 refresh
@Autowired
private ElasticsearchOperations operations;
void refreshIndex() {
operations.indexOps(ProductDocument.class).refresh();
}
// 方式二:测试索引配置 refresh_interval=0(立即刷新,只用于测试)
// 在测试专用的 es/product-settings-test.json 里
{
"settings": {
"refresh_interval": "0s",
"number_of_replicas": 0,
"number_of_shards": 1
}
}坑 2:Mapping 冲突导致索引创建失败
现象: 第二次跑测试时,报 mapping conflict: cannot change type of field [price] from [integer] to [double]。
原因: 第一次测试创建了索引,定义 price 为 integer。第二次测试时容器复用,索引已存在,但代码里 price 的 mapping 改成了 double,ES 不允许修改已有字段的类型。
解法:
// 在 @BeforeAll 或 @BeforeEach 里重建索引
@BeforeAll
static void createIndex(@Autowired ElasticsearchOperations operations) {
IndexOperations indexOps = operations.indexOps(ProductDocument.class);
if (indexOps.exists()) {
indexOps.delete();
}
indexOps.createWithMapping();
}坑 3:Docker 内存不足导致 ES 容器 OOM
现象: CI 机器上 ES 容器总是启动失败,日志显示 OpenJDK 64-Bit Server VM warning: INFO: os::commit_memory(...) failed 或直接 OOM Killed。
原因: Elasticsearch 8.x 默认 JVM 堆内存是 4GB,CI 机器 Docker 内存限制比较小,直接 OOM。
解法:
@Container
static ElasticsearchContainer elasticsearch = new ElasticsearchContainer(...)
.withEnv("ES_JAVA_OPTS", "-Xms256m -Xmx256m") // 测试用降低内存
.withEnv("xpack.ml.enabled", "false"); // 关闭 ML,省内存注意:-Xmx256m 在功能测试中没问题,但如果跑性能测试会影响结果,需要根据实际情况调整。
六、Aggregation(聚合)测试
ES 聚合是另一个 Mock 完全无法测的场景:
@Test
void 按分类聚合_统计各类商品数量_结果正确() {
// given
productRepository.saveAll(List.of(
buildProduct("手机", "iPhone"),
buildProduct("手机", "华为"),
buildProduct("手机", "小米"),
buildProduct("电脑", "MacBook"),
buildProduct("电脑", "ThinkPad")
));
refreshIndex();
// when
Map<String, Long> categoryCount = searchService.aggregateByCategory();
// then
assertThat(categoryCount).containsEntry("手机", 3L);
assertThat(categoryCount).containsEntry("电脑", 2L);
}七、测试环境配置最佳实践
application-test.yml 的 ES 配置:
spring:
elasticsearch:
# 由 DynamicPropertySource 注入,这里不配置
connection-timeout: 10s
socket-timeout: 30sES 集成测试的启动比较慢(ES 容器启动需要 30 秒左右),建议配置容器复用,或者把 ES 相关测试单独分组,不每次都跑。
那次搜索功能的 Bug 让我深刻理解了一个道理:搜索质量是业务价值,不是简单的功能验证。Mock 不能测出分词器、相关性评分的问题,只有真实 ES 才能给你真实的答案。
