diff --git a/CHANGELOG.md b/CHANGELOG.md index 370e1c4..623aacf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,11 @@ ## [Unreleased] +### Added + +- **`query-counter.max-queries.per-test` 로 한 테스트의 쿼리 수 상한을 둘 수 있습니다. 테스트에는 아무것도 안 적습니다.** 반복문 안에서 리포지토리를 부르면 쿼리 3개가 200개가 되는데, 그것이 어느 테스트에서 일어나든 잡습니다. 손으로 적는 어서션이 한 테스트의 정확한 수를 말하는 것과 달리 모든 테스트의 대략의 수를 말합니다. 기본은 0(상한 없음)입니다 +- **`query-counter.max-queries.report` 로 테스트마다 쿼리 수를 로그로 남깁니다.** 상한을 어림수로 잡지 않고 지금 스위트의 최대값을 보고 정하기 위한 것입니다. 테스트 설정에서 `logging.level.root: warn` 을 두면 이 보고가 걸러지므로, 그 경우에는 경고로 알립니다 + ## [0.4.0] - 2026-08-19 ### Added diff --git a/README.ko.md b/README.ko.md index 9f90e64..29f8a8f 100644 --- a/README.ko.md +++ b/README.ko.md @@ -362,6 +362,8 @@ No query was recorded. Is query-counter.enabled=true set in your test configurat | `query-counter.n-plus-one.enabled` | `false` | 테스트에 아무것도 안 적고, 모든 테스트에서 N+1 을 검사합니다 | | `query-counter.n-plus-one.fail` | `false` | 그 검사가 N+1 을 찾으면 실패시킵니다. 꺼져 있으면 경고 로그만 남깁니다 | | `query-counter.other-threads.enabled` | `false` | 테스트가 다른 스레드에서 일으킨 쿼리도 셉니다. 인수 테스트에서 서버가 처리한 요청 같은 것입니다 | +| `query-counter.max-queries.per-test` | `0` | 한 테스트가 이 수를 넘으면 실패시킵니다. 0 이면 상한이 없습니다 | +| `query-counter.max-queries.report` | `false` | 테스트마다 쿼리 수를 로그로 남깁니다. 상한을 정할 때 씁니다 | ```yaml query-counter: @@ -373,10 +375,51 @@ query-counter: fail: false other-threads: enabled: false + max-queries: + per-test: 0 + report: false ``` 모든 프로퍼티가 IDE 자동완성에 설명과 함께 표시됩니다. +### 테스트별 쿼리 수 상한 + +손으로 적는 어서션은 한 테스트에 대해 정확한 수를 말합니다. 상한은 **모든 테스트에 대해 대략의 +수**를 말하고, 어느 테스트에도 아무것도 안 적습니다. + +```yaml +query-counter: + enabled: true + max-queries: + per-test: 50 +``` + +**정확한 어서션과 잡는 것이 다릅니다.** 반복문 안에서 리포지토리를 부르면 쿼리 3개가 200개가 +되는데, 상한은 그것이 어느 테스트에서 일어나든 잡습니다. 3개가 12개로 조용히 늘어나는 것은 쓸만한 +상한 아래에 머무릅니다. 그건 괜찮습니다. 상한은 폭주를 막는 그물이고 회귀 검사가 아닙니다. + +숫자는 어림수로 잡지 말고 **지금 스위트가 실제로 쓰는 값**을 보고 정하세요. + +```yaml +query-counter: + enabled: true + max-queries: + report: true +logging: + level: + soon.springtestutil: info +``` + +이러면 테스트마다 한 줄씩 나오므로 스위트의 최대값을 찾기 쉽고, 그보다 여유 있게 상한을 잡으면 +지금 통과하는 것이 깨지지 않습니다. **로깅 한 줄이 중요합니다.** 테스트 설정에서 +`logging.level.root: warn` 을 두면 보고가 걸러집니다. 그 경우에는 경고가 그 사실을 알려 주므로, +켰는데 아무것도 안 세진 것처럼 보이는 일은 없습니다. + +상한에 포함되는데 잊기 쉬운 것이 둘 있습니다. 테스트 준비 과정의 쿼리, 예컨대 테스트 사이에 +테이블을 비우는 리스너의 쿼리도 다른 것과 똑같이 세집니다. 그리고 `other-threads.enabled` 를 +켜면 서버가 요청을 처리하며 날린 쿼리도 세집니다. 둘 다 그 테스트가 실제로 일으킨 것이므로, +정할 숫자는 보고가 보여주는 그 값입니다. + ### 다른 스레드에서 나간 쿼리도 세기 실제 HTTP 요청을 보내는 테스트는 쿼리가 서버 쪽 워커 스레드에서 나갑니다. 기록은 스레드마다 diff --git a/README.md b/README.md index c48e6af..5e7123b 100644 --- a/README.md +++ b/README.md @@ -364,6 +364,8 @@ No query was recorded. Is query-counter.enabled=true set in your test configurat | `query-counter.n-plus-one.enabled` | `false` | Check every test for an N+1, with nothing written in the test | | `query-counter.n-plus-one.fail` | `false` | Fail the test when that check finds one. When false it is logged as a warning | | `query-counter.other-threads.enabled` | `false` | Count queries a test caused on another thread, such as a request handled by the server in an acceptance test | +| `query-counter.max-queries.per-test` | `0` | Fail a test that runs more queries than this. 0 means no limit | +| `query-counter.max-queries.report` | `false` | Log the query count of every test, to help pick the limit | ```yaml query-counter: @@ -375,10 +377,52 @@ query-counter: fail: false other-threads: enabled: false + max-queries: + per-test: 0 + report: false ``` Every property appears with a description in IDE autocompletion. +### A ceiling on queries per test + +An assertion written by hand says an exact number for one test. A ceiling says a rough number for +every test, with nothing written in any of them. + +```yaml +query-counter: + enabled: true + max-queries: + per-test: 50 +``` + +**It catches a different thing than an exact assertion does.** A loop around a repository call turns +3 queries into 200, and the ceiling catches that in whichever test runs it. A count creeping from 3 +to 12 stays under any useful ceiling, and that is fine: the ceiling is a net for runaway queries, not +a regression check. + +Pick the number from what the suite runs today rather than from a round figure. + +```yaml +query-counter: + enabled: true + max-queries: + report: true +logging: + level: + soon.springtestutil: info +``` + +That logs one line per test, so the highest number in the suite is easy to find, and then a ceiling +above it will not fail anything that passes today. **The logging line matters**: a test setup with +`logging.level.root: warn` swallows the report. If that happens, a warning says so rather than +leaving the impression that nothing was counted. + +Two things count toward the ceiling that are easy to forget. Queries a test setup runs, including a +listener that truncates tables between tests, are counted like any other. And with +`other-threads.enabled` on, queries the server ran for a request are counted too. Both are part of +what the test actually caused, so the number to pick is the one the report shows. + ### Counting queries that ran on another thread A test that sends a real HTTP request has its queries run by the server, on a worker thread. What diff --git a/src/main/java/soon/springtestutil/config/AutoConfig.java b/src/main/java/soon/springtestutil/config/AutoConfig.java index 14e4d04..59920c1 100644 --- a/src/main/java/soon/springtestutil/config/AutoConfig.java +++ b/src/main/java/soon/springtestutil/config/AutoConfig.java @@ -6,6 +6,7 @@ import org.springframework.context.annotation.Configuration; import org.springframework.core.env.Environment; import soon.springtestutil.querycount.NPlusOneCheck; +import soon.springtestutil.querycount.QueryLimit; /** * query-counter 자동 설정입니다. @@ -35,10 +36,15 @@ static DataSourceProxyBeanPostProcessor dataSourceProxyBeanPostProcessor(Environ .getProperty("query-counter.n-plus-one.fail", Boolean.class, false); boolean collectOtherThreads = environment .getProperty("query-counter.other-threads.enabled", Boolean.class, false); + int maxPerTest = environment + .getProperty("query-counter.max-queries.per-test", Integer.class, 0); + boolean reportCounts = environment + .getProperty("query-counter.max-queries.report", Boolean.class, false); return new DataSourceProxyBeanPostProcessor( loggingEnabled, NPlusOneCheck.of(nPlusOneEnabled, nPlusOneFail), - collectOtherThreads + collectOtherThreads, + QueryLimit.of(maxPerTest, reportCounts) ); } diff --git a/src/main/java/soon/springtestutil/config/DataSourceProxyBeanPostProcessor.java b/src/main/java/soon/springtestutil/config/DataSourceProxyBeanPostProcessor.java index 748b3da..9539ca2 100644 --- a/src/main/java/soon/springtestutil/config/DataSourceProxyBeanPostProcessor.java +++ b/src/main/java/soon/springtestutil/config/DataSourceProxyBeanPostProcessor.java @@ -13,6 +13,7 @@ import org.springframework.core.PriorityOrdered; import org.springframework.util.ReflectionUtils; import soon.springtestutil.querycount.NPlusOneCheck; +import soon.springtestutil.querycount.QueryLimit; import soon.springtestutil.querycount.datasource.QueryCountListener; import javax.sql.DataSource; @@ -40,22 +41,34 @@ public class DataSourceProxyBeanPostProcessor implements BeanPostProcessor, Prio private final boolean collectOtherThreads; + private final QueryLimit queryLimit; + public DataSourceProxyBeanPostProcessor(boolean loggingEnabled) { - this(loggingEnabled, NPlusOneCheck.OFF, false); + this(loggingEnabled, NPlusOneCheck.OFF, false, QueryLimit.OFF); } public DataSourceProxyBeanPostProcessor(boolean loggingEnabled, NPlusOneCheck nPlusOneCheck) { - this(loggingEnabled, nPlusOneCheck, false); + this(loggingEnabled, nPlusOneCheck, false, QueryLimit.OFF); } public DataSourceProxyBeanPostProcessor( boolean loggingEnabled, NPlusOneCheck nPlusOneCheck, boolean collectOtherThreads + ) { + this(loggingEnabled, nPlusOneCheck, collectOtherThreads, QueryLimit.OFF); + } + + public DataSourceProxyBeanPostProcessor( + boolean loggingEnabled, + NPlusOneCheck nPlusOneCheck, + boolean collectOtherThreads, + QueryLimit queryLimit ) { this.loggingEnabled = loggingEnabled; this.nPlusOneCheck = nPlusOneCheck; this.collectOtherThreads = collectOtherThreads; + this.queryLimit = queryLimit; } /** @@ -83,7 +96,8 @@ public Object postProcessAfterInitialization( factory.setProxyTargetClass(true); // 다양한 DataSource 구현체를 지원하기 위해 CGLIB 프록시 사용 factory.addInterface(QueryCountedDataSource.class); factory.addAdvice(ProxyDataSourceInterceptor.of( - dataSource, this.loggingEnabled, this.nPlusOneCheck, this.collectOtherThreads)); + dataSource, this.loggingEnabled, this.nPlusOneCheck, this.collectOtherThreads, + this.queryLimit)); return factory.getProxy(); } @@ -142,13 +156,14 @@ private static ProxyDataSourceInterceptor of( DataSource dataSource, boolean loggingEnabled, NPlusOneCheck nPlusOneCheck, - boolean collectOtherThreads + boolean collectOtherThreads, + QueryLimit queryLimit ) { ChainListener listener = new ChainListener(); if (loggingEnabled) { listener.addListener(new SLF4JQueryLoggingListener()); } - listener.addListener(new QueryCountListener(nPlusOneCheck, collectOtherThreads)); + listener.addListener(new QueryCountListener(nPlusOneCheck, collectOtherThreads, queryLimit)); return new ProxyDataSourceInterceptor( ProxyDataSourceBuilder.create(dataSource) diff --git a/src/main/java/soon/springtestutil/config/QueryCounterProperties.java b/src/main/java/soon/springtestutil/config/QueryCounterProperties.java index 2621490..77e4c12 100644 --- a/src/main/java/soon/springtestutil/config/QueryCounterProperties.java +++ b/src/main/java/soon/springtestutil/config/QueryCounterProperties.java @@ -18,6 +18,9 @@ * fail: false * other-threads: * enabled: false + * max-queries: + * per-test: 0 + * report: false * } */ @ConfigurationProperties(prefix = "query-counter") @@ -34,6 +37,8 @@ public class QueryCounterProperties { private final OtherThreads otherThreads = new OtherThreads(); + private final MaxQueries maxQueries = new MaxQueries(); + public boolean isEnabled() { return this.enabled; } @@ -54,6 +59,50 @@ public OtherThreads getOtherThreads() { return this.otherThreads; } + public MaxQueries getMaxQueries() { + return this.maxQueries; + } + + /** + * A ceiling on how many queries one test may run. + * + *
Off by default. A ceiling catches a test that suddenly runs hundreds of queries, which + * is what a loop around a repository call looks like. It does not catch a count creeping + * from 3 to 12, and it is not meant to. + * + *
Pick the number from what the suite runs today. {@code report} logs the count of every + * test so that number is easy to find. + */ + public static class MaxQueries { + + /** + * How many queries one test may run. 0 means no limit. + */ + private int perTest = 0; + + /** + * Whether to log the query count of every test, to help pick the limit. + */ + private boolean report = false; + + public int getPerTest() { + return this.perTest; + } + + public void setPerTest(int perTest) { + this.perTest = perTest; + } + + public boolean isReport() { + return this.report; + } + + public void setReport(boolean report) { + this.report = report; + } + + } + /** * Counting queries that a test causes on another thread. * diff --git a/src/main/java/soon/springtestutil/querycount/QueryLimit.java b/src/main/java/soon/springtestutil/querycount/QueryLimit.java new file mode 100644 index 0000000..4e21561 --- /dev/null +++ b/src/main/java/soon/springtestutil/querycount/QueryLimit.java @@ -0,0 +1,37 @@ +package soon.springtestutil.querycount; + +/** + * How many queries one test may run, and whether to report what it ran. + * + *
Set with {@code query-counter.max-queries.per-test} and + * {@code query-counter.max-queries.report}. Both are off by default, so adding this library + * cannot change how existing tests behave. + * + *
A limit catches a test that suddenly runs hundreds of queries, which is what a loop + * around a repository call looks like. It does not catch a count creeping from 3 to 12. + * + * @param maxPerTest the limit, or 0 for no limit + * @param report whether to log the query count of every test + */ +public record QueryLimit(int maxPerTest, boolean report) { + + /** No limit and no report. */ + public static final QueryLimit OFF = new QueryLimit(0, false); + + public static QueryLimit of(int maxPerTest, boolean report) { + if (maxPerTest <= 0 && !report) { + return OFF; + } + return new QueryLimit(Math.max(maxPerTest, 0), report); + } + + /** Whether anything needs to happen at the end of a test. */ + public boolean isActive() { + return this.maxPerTest > 0 || this.report; + } + + public boolean exceededBy(long total) { + return this.maxPerTest > 0 && total > this.maxPerTest; + } + +} diff --git a/src/main/java/soon/springtestutil/querycount/assertion/QueryLimitWatch.java b/src/main/java/soon/springtestutil/querycount/assertion/QueryLimitWatch.java new file mode 100644 index 0000000..ef2cefc --- /dev/null +++ b/src/main/java/soon/springtestutil/querycount/assertion/QueryLimitWatch.java @@ -0,0 +1,76 @@ +package soon.springtestutil.querycount.assertion; + +import lombok.extern.slf4j.Slf4j; +import soon.springtestutil.core.context.TestContextHolder; +import soon.springtestutil.querycount.QueryLimit; +import soon.springtestutil.querycount.QueryType; +import soon.springtestutil.querycount.context.QueryCountContext; + +import java.util.EnumMap; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * Applies the per-test query limit, and reports counts when asked. + * + *
Called at the end of every test by the listener and the extension, after the assertions + * written by hand and after the N+1 check. Those say something more specific, so they should + * fail first. + */ +@Slf4j +public final class QueryLimitWatch { + + /** + * 로그 수준 안내를 한 번만 내기 위한 표시입니다. + * + *
테스트 사이에 되돌릴 필요가 없는 값입니다. 한 번 알렸으면 그대로 두는 것이 맞는
+ * 동작이므로, 리셋되지 않아 문제가 됐던 예전의 static 플래그와 성질이 다릅니다.
+ */
+ private static final AtomicBoolean warnedAboutLogLevel = new AtomicBoolean();
+
+ private QueryLimitWatch() {
+ throw new UnsupportedOperationException("Utility class");
+ }
+
+ /**
+ * Reports the count and fails when the limit is exceeded.
+ *
+ * @throws AssertionError when the test ran more queries than the limit allows
+ */
+ public static void run() {
+ QueryLimit limit = QueryCountContext.getQueryLimit();
+ if (!limit.isActive()) {
+ return;
+ }
+
+ EnumMap 테스트 설정에서 {@code logging.level.root: warn} 을 두는 프로젝트가 흔합니다. 그러면
+ * 보고가 걸러져 아무것도 안 보이는데, 켰는데 아무 일도 없는 것으로 읽힙니다.
+ */
+ private static void warnIfReportIsInvisible() {
+ if (log.isInfoEnabled() || !warnedAboutLogLevel.compareAndSet(false, true)) {
+ return;
+ }
+ log.warn("query-counter.max-queries.report is on but info logging is off for {}, "
+ + "so nothing will be printed. Set logging.level.soon.springtestutil=info.",
+ QueryLimitWatch.class.getName());
+ }
+
+}
diff --git a/src/main/java/soon/springtestutil/querycount/context/QueryCountContext.java b/src/main/java/soon/springtestutil/querycount/context/QueryCountContext.java
index d0c5040..ee0e7af 100644
--- a/src/main/java/soon/springtestutil/querycount/context/QueryCountContext.java
+++ b/src/main/java/soon/springtestutil/querycount/context/QueryCountContext.java
@@ -1,6 +1,7 @@
package soon.springtestutil.querycount.context;
import soon.springtestutil.querycount.NPlusOneCheck;
+import soon.springtestutil.querycount.QueryLimit;
import soon.springtestutil.querycount.QueryType;
import java.util.ArrayList;
@@ -22,6 +23,9 @@ public final class QueryCountContext {
private static final ThreadLocal Internal wiring, carried the same way as the N+1 mode and for the same reason: the
+ * setting lives in the application context and the listener that ends each test must not
+ * touch that context.
+ */
+ public static void requestQueryLimit(QueryLimit limit) {
+ queryLimit.set(limit);
+ }
+
+ /**
+ * Returns the query limit for the current test, {@link QueryLimit#OFF} by default.
+ */
+ public static QueryLimit getQueryLimit() {
+ return queryLimit.get();
+ }
+
public static List 설정은 애플리케이션 컨텍스트에 있고 테스트를 끝내는 리스너는 그 컨텍스트를 만질 수
+ * 없으므로, 값이 DataSource 프록시를 타고 ThreadLocal 로 옵니다. 그 경로가 실제 쿼리 실행으로
+ * 이어지는지는 이 테스트만 봅니다.
+ */
+@EnableAutoConfiguration
+@SpringBootTest(classes = AutoConfig.class, properties = {
+ "query-counter.enabled=true",
+ "query-counter.max-queries.per-test=2"
+})
+class QueryLimitIntegrationTest {
+
+ @Autowired
+ JdbcTemplate jdbcTemplate;
+
+ @BeforeEach
+ void initSchema() {
+ jdbcTemplate.execute("DROP TABLE IF EXISTS member");
+ jdbcTemplate.execute("CREATE TABLE member (id INT PRIMARY KEY)");
+ QueryCountContext.clear();
+ }
+
+ @DisplayName("프로퍼티로 준 상한이 실제 쿼리 실행을 통해 테스트 경계까지 전달된다")
+ @Test
+ void limitShouldReachTestBoundary() {
+ // given
+ jdbcTemplate.update("INSERT INTO member (id) VALUES (1)");
+
+ // when
+ QueryLimit limit = QueryCountContext.getQueryLimit();
+
+ // then
+ assertThat(limit).isEqualTo(QueryLimit.of(2, false));
+
+ // 상한을 넘겨 놓고 판정을 부른다. 여기서 비우지 않으면 테스트가 끝날 때 리스너가
+ // 같은 초과를 찾아 이 테스트를 실패시킨다. 실패시키는 것이 이 기능의 목적이다.
+ jdbcTemplate.queryForObject("SELECT count(*) FROM member", Integer.class);
+ jdbcTemplate.queryForObject("SELECT count(*) FROM member", Integer.class);
+ assertThatThrownBy(QueryLimitWatch::run)
+ .isInstanceOf(AssertionError.class)
+ .hasMessageContaining("the limit is 2");
+
+ QueryCountContext.clear();
+ }
+
+}
diff --git a/src/test/java/soon/springtestutil/querycount/assertion/QueryLimitWatchTest.java b/src/test/java/soon/springtestutil/querycount/assertion/QueryLimitWatchTest.java
new file mode 100644
index 0000000..08588a2
--- /dev/null
+++ b/src/test/java/soon/springtestutil/querycount/assertion/QueryLimitWatchTest.java
@@ -0,0 +1,143 @@
+package soon.springtestutil.querycount.assertion;
+
+import ch.qos.logback.classic.Level;
+import ch.qos.logback.classic.Logger;
+import ch.qos.logback.classic.spi.ILoggingEvent;
+import ch.qos.logback.core.read.ListAppender;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.slf4j.LoggerFactory;
+import soon.springtestutil.querycount.QueryLimit;
+import soon.springtestutil.querycount.QueryType;
+import soon.springtestutil.querycount.context.QueryCountContext;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+@DisplayName("테스트별 쿼리 수 상한 검사")
+class QueryLimitWatchTest {
+
+ private Logger logger;
+
+ private ListAppender> queries = ThreadLocal
.withInitial(ArrayList::new);
@@ -80,6 +84,24 @@ public static NPlusOneCheck getNPlusOneCheck() {
return nPlusOneCheck.get();
}
+ /**
+ * Records the per-test query limit for the current test.
+ *
+ *