From df4921af157e52e1b17c03786a790fb73d2a54ff Mon Sep 17 00:00:00 2001 From: jjh75607 Date: Wed, 19 Aug 2026 15:34:44 +0900 Subject: [PATCH] =?UTF-8?q?Feat:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=EB=B3=84=20?= =?UTF-8?q?=EC=BF=BC=EB=A6=AC=20=EC=88=98=20=EC=83=81=ED=95=9C=EC=9D=84=20?= =?UTF-8?q?=EB=91=98=20=EC=88=98=20=EC=9E=88=EA=B2=8C=20=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 손으로 적는 어서션은 한 테스트의 정확한 수를 말한다. 그래서 도입 비용이 테스트 개수에 비례하고, 테스트가 이미 수백 개면 못 들어간다. 상한은 **모든 테스트에 대해 대략의 수**를 말하고 어느 테스트에도 아무것도 안 적는다. `query-counter.max-queries.per-test` 다. 잡는 것이 어서션과 다르다. 반복문 안에서 리포지토리를 부르면 쿼리 3개가 200개가 되는데 그것이 어느 테스트에서 일어나든 잡는다. 3개가 12개로 조용히 늘어나는 것은 쓸만한 상한 아래에 머무른다. 그건 괜찮다 — 상한은 폭주를 막는 그물이고 회귀 검사가 아니다. `query-counter.max-queries.report` 를 함께 둔 이유가 있다. 상한을 어림수로 잡으면 지금 통과하는 테스트가 깨지거나 아무것도 안 걸린다. 보고 모드로 한 번 돌려 최대값을 보고 정하는 것이 맞다. **보고가 조용히 안 보이는 구성이 있다.** 테스트 설정에서 `logging.level.root: warn` 을 두는 프로젝트가 흔하고 그러면 info 보고가 걸러진다. 실제 프로젝트에 켜 봤더니 그렇게 됐다. 켰는데 아무 일도 없는 것으로 읽히므로, 로거의 info 가 닫혀 있으면 경고로 한 번 알린다. 설정 전달은 N+1 모드와 같은 경로다. AutoConfig 가 읽어 리스너까지 넘기고 리스너가 ThreadLocal 에 실어 나른다. 판정은 어서션과 N+1 다음에 부른다. 그쪽이 더 구체적인 말을 하므로 먼저 실패해야 한다. 실제 프로젝트(테스트 164개)에서 확인했다. 보고 모드에서 최대 27, 중앙 19 가 나왔고, 같은 스위트를 두 번 돌려 75개 테스트의 수가 하나도 다르지 않은 것도 확인했다. 상한 20 으로 두면 28개가 걸리고 50 으로 두면 전부 통과한다. 테스트 준비 과정의 TRUNCATE 같은 쿼리도 함께 세지므로 그 사실을 README 두 벌에 적었다. 테스트 열 개를 더했다. 지원 버전 셋을 로컬에서 확인했다. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 5 + README.ko.md | 43 ++++++ README.md | 44 ++++++ .../springtestutil/config/AutoConfig.java | 8 +- .../DataSourceProxyBeanPostProcessor.java | 25 ++- .../config/QueryCounterProperties.java | 49 ++++++ .../springtestutil/querycount/QueryLimit.java | 37 +++++ .../querycount/assertion/QueryLimitWatch.java | 76 ++++++++++ .../querycount/context/QueryCountContext.java | 23 +++ .../datasource/QueryCountListener.java | 17 ++- .../QueryCountTestExecutionListener.java | 2 + .../extension/QueryCountTestExtension.java | 2 + .../querycount/QueryLimitTest.java | 53 +++++++ .../assertion/QueryLimitIntegrationTest.java | 64 ++++++++ .../assertion/QueryLimitWatchTest.java | 143 ++++++++++++++++++ 15 files changed, 583 insertions(+), 8 deletions(-) create mode 100644 src/main/java/soon/springtestutil/querycount/QueryLimit.java create mode 100644 src/main/java/soon/springtestutil/querycount/assertion/QueryLimitWatch.java create mode 100644 src/test/java/soon/springtestutil/querycount/QueryLimitTest.java create mode 100644 src/test/java/soon/springtestutil/querycount/assertion/QueryLimitIntegrationTest.java create mode 100644 src/test/java/soon/springtestutil/querycount/assertion/QueryLimitWatchTest.java 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 counts = QueryCountContext.getQueryCounts(); + long total = counts.values().stream().mapToLong(Long::longValue).sum(); + + if (limit.report()) { + // 상한을 무엇으로 잡을지 정하려면 지금 몇 개가 나가는지 알아야 한다. + warnIfReportIsInvisible(); + log.info("{}{} queries: {}", TestContextHolder.getContextInfo(), total, counts); + } + + if (limit.exceededBy(total)) { + throw new AssertionError(String.format( + "%sQuery limit exceeded: %d queries ran, the limit is %d%n %s", + TestContextHolder.getContextInfo(), total, limit.maxPerTest(), counts)); + } + } + + /** + * 보고를 켰는데 이 로거의 info 가 닫혀 있으면 알립니다. + * + *

테스트 설정에서 {@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 nPlusOneCheck = ThreadLocal.withInitial(() -> NPlusOneCheck.OFF); + private static final ThreadLocal queryLimit = + ThreadLocal.withInitial(() -> QueryLimit.OFF); + private static final ThreadLocal> 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. + * + *

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 getQueries() { return new ArrayList<>(queries.get()); } @@ -97,6 +119,7 @@ public static void clear() { queries.remove(); nPlusOneCheck.remove(); OtherThreadQueries.clear(); + queryLimit.remove(); } } diff --git a/src/main/java/soon/springtestutil/querycount/datasource/QueryCountListener.java b/src/main/java/soon/springtestutil/querycount/datasource/QueryCountListener.java index 3ae7d97..dd11dcb 100644 --- a/src/main/java/soon/springtestutil/querycount/datasource/QueryCountListener.java +++ b/src/main/java/soon/springtestutil/querycount/datasource/QueryCountListener.java @@ -7,6 +7,7 @@ import net.ttddyy.dsproxy.proxy.ParameterSetOperation; import soon.springtestutil.core.context.TestContextHolder; import soon.springtestutil.querycount.NPlusOneCheck; +import soon.springtestutil.querycount.QueryLimit; import soon.springtestutil.querycount.QueryType; import soon.springtestutil.querycount.context.OtherThreadQueries; import soon.springtestutil.querycount.context.QueryCountContext; @@ -25,17 +26,28 @@ public class QueryCountListener implements QueryExecutionListener { private final boolean collectOtherThreads; + private final QueryLimit queryLimit; + public QueryCountListener() { - this(NPlusOneCheck.OFF, false); + this(NPlusOneCheck.OFF, false, QueryLimit.OFF); } public QueryCountListener(NPlusOneCheck nPlusOneCheck) { - this(nPlusOneCheck, false); + this(nPlusOneCheck, false, QueryLimit.OFF); } public QueryCountListener(NPlusOneCheck nPlusOneCheck, boolean collectOtherThreads) { + this(nPlusOneCheck, collectOtherThreads, QueryLimit.OFF); + } + + public QueryCountListener( + NPlusOneCheck nPlusOneCheck, + boolean collectOtherThreads, + QueryLimit queryLimit + ) { this.nPlusOneCheck = nPlusOneCheck; this.collectOtherThreads = collectOtherThreads; + this.queryLimit = queryLimit; } /** @@ -72,6 +84,7 @@ public void afterQuery(ExecutionInfo execInfo, List queryInfoList) { // 설정은 애플리케이션 컨텍스트에 있고 테스트를 끝내는 리스너는 그 컨텍스트를 만질 수 // 없다. 그래서 기록하는 이쪽이 모드를 같은 ThreadLocal 에 실어 나른다. QueryCountContext.requestNPlusOneCheck(nPlusOneCheck); + QueryCountContext.requestQueryLimit(queryLimit); Long elapsedMs = null; try { diff --git a/src/main/java/soon/springtestutil/querycount/extension/QueryCountTestExecutionListener.java b/src/main/java/soon/springtestutil/querycount/extension/QueryCountTestExecutionListener.java index 0b63726..6bc8cdb 100644 --- a/src/main/java/soon/springtestutil/querycount/extension/QueryCountTestExecutionListener.java +++ b/src/main/java/soon/springtestutil/querycount/extension/QueryCountTestExecutionListener.java @@ -5,6 +5,7 @@ import soon.springtestutil.core.context.TestContextHolder; import soon.springtestutil.querycount.assertion.NPlusOneWatch; import soon.springtestutil.querycount.assertion.QueryCounterAssertion; +import soon.springtestutil.querycount.assertion.QueryLimitWatch; import soon.springtestutil.querycount.context.QueryCountContext; /** @@ -50,6 +51,7 @@ public void afterTestMethod(TestContext testContext) { if (testContext.getTestException() == null) { QueryCounterAssertion.verifyPending(); NPlusOneWatch.run(); + QueryLimitWatch.run(); } } finally { diff --git a/src/main/java/soon/springtestutil/querycount/extension/QueryCountTestExtension.java b/src/main/java/soon/springtestutil/querycount/extension/QueryCountTestExtension.java index 6843285..14f1b4d 100644 --- a/src/main/java/soon/springtestutil/querycount/extension/QueryCountTestExtension.java +++ b/src/main/java/soon/springtestutil/querycount/extension/QueryCountTestExtension.java @@ -6,6 +6,7 @@ import soon.springtestutil.core.context.TestContextHolder; import soon.springtestutil.querycount.assertion.NPlusOneWatch; import soon.springtestutil.querycount.assertion.QueryCounterAssertion; +import soon.springtestutil.querycount.assertion.QueryLimitWatch; import soon.springtestutil.querycount.context.QueryCountContext; /** @@ -45,6 +46,7 @@ public void afterEach(ExtensionContext context) { QueryCountContext.mergeOtherThreadQueries(); QueryCounterAssertion.verifyPending(); NPlusOneWatch.run(); + QueryLimitWatch.run(); } } finally { diff --git a/src/test/java/soon/springtestutil/querycount/QueryLimitTest.java b/src/test/java/soon/springtestutil/querycount/QueryLimitTest.java new file mode 100644 index 0000000..7717403 --- /dev/null +++ b/src/test/java/soon/springtestutil/querycount/QueryLimitTest.java @@ -0,0 +1,53 @@ +package soon.springtestutil.querycount; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("테스트별 쿼리 수 상한 값") +class QueryLimitTest { + + @DisplayName("둘 다 안 주면 아무 일도 하지 않는다") + @Test + void ofShouldReturnOffWhenNothingIsSet() { + // given, when, then + assertThat(QueryLimit.of(0, false)).isEqualTo(QueryLimit.OFF); + assertThat(QueryLimit.OFF.isActive()).isFalse(); + } + + @DisplayName("보고만 켜도 테스트 끝에 할 일이 있다") + @Test + void ofShouldBeActiveWithReportOnly() { + // given, when + QueryLimit limit = QueryLimit.of(0, true); + + // then + assertThat(limit.isActive()).isTrue(); + assertThat(limit.exceededBy(1000)).isFalse(); + } + + @DisplayName("상한을 넘었는지는 상한이 있을 때만 판정한다") + @Test + void exceededByShouldNeedALimit() { + // given + QueryLimit withLimit = QueryLimit.of(10, false); + + // when, then + assertThat(withLimit.exceededBy(10)).isFalse(); + assertThat(withLimit.exceededBy(11)).isTrue(); + assertThat(QueryLimit.OFF.exceededBy(Long.MAX_VALUE)).isFalse(); + } + + @DisplayName("음수 상한은 0으로 본다") + @Test + void ofShouldTreatNegativeAsNoLimit() { + // given, when + QueryLimit limit = QueryLimit.of(-5, true); + + // then + assertThat(limit.maxPerTest()).isZero(); + assertThat(limit.exceededBy(100)).isFalse(); + } + +} diff --git a/src/test/java/soon/springtestutil/querycount/assertion/QueryLimitIntegrationTest.java b/src/test/java/soon/springtestutil/querycount/assertion/QueryLimitIntegrationTest.java new file mode 100644 index 0000000..b9252b8 --- /dev/null +++ b/src/test/java/soon/springtestutil/querycount/assertion/QueryLimitIntegrationTest.java @@ -0,0 +1,64 @@ +package soon.springtestutil.querycount.assertion; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import soon.springtestutil.config.AutoConfig; +import soon.springtestutil.querycount.QueryLimit; +import soon.springtestutil.querycount.context.QueryCountContext; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * 프로퍼티에서 테스트 경계까지 상한이 전달되는지 봅니다. + * + *

설정은 애플리케이션 컨텍스트에 있고 테스트를 끝내는 리스너는 그 컨텍스트를 만질 수 + * 없으므로, 값이 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 appender; + + @BeforeEach + void setUp() { + QueryCountContext.clear(); + logger = (Logger) LoggerFactory.getLogger(QueryLimitWatch.class); + appender = new ListAppender<>(); + appender.start(); + logger.addAppender(appender); + } + + @AfterEach + void tearDown() { + logger.detachAppender(appender); + QueryCountContext.clear(); + } + + private void recordQueries(int count) { + for (int i = 0; i < count; i++) { + QueryCountContext.addQuery(QueryType.SELECT, "select " + i, 1L, List.of()); + } + } + + @DisplayName("설정하지 않으면 쿼리가 많아도 아무 일도 하지 않는다") + @Test + void runShouldDoNothingWhenOff() { + // given + recordQueries(500); + + // when, then + assertThatCode(QueryLimitWatch::run).doesNotThrowAnyException(); + assertThat(appender.list).isEmpty(); + } + + @DisplayName("상한을 넘으면 실패시키고 실제 개수와 상한을 함께 낸다") + @Test + void runShouldFailOverTheLimit() { + // given + recordQueries(12); + QueryCountContext.requestQueryLimit(QueryLimit.of(10, false)); + + // when, then + assertThatThrownBy(QueryLimitWatch::run) + .isInstanceOf(AssertionError.class) + .hasMessageContaining("Query limit exceeded") + .hasMessageContaining("12 queries ran") + .hasMessageContaining("the limit is 10"); + } + + @DisplayName("상한과 같으면 통과한다") + @Test + void runShouldPassAtTheLimit() { + // given + recordQueries(10); + QueryCountContext.requestQueryLimit(QueryLimit.of(10, false)); + + // when, then + assertThatCode(QueryLimitWatch::run).doesNotThrowAnyException(); + } + + @DisplayName("보고만 켜면 개수를 로그로 남기고 실패시키지 않는다") + @Test + void runShouldReportWithoutFailing() { + // given - 상한을 무엇으로 잡을지 정하려고 쓰는 모드다 + recordQueries(37); + QueryCountContext.requestQueryLimit(QueryLimit.of(0, true)); + + // when + QueryLimitWatch.run(); + + // then + assertThat(appender.list) + .singleElement() + .satisfies(event -> { + assertThat(event.getLevel()).isEqualTo(Level.INFO); + assertThat(event.getFormattedMessage()).contains("37 queries"); + }); + } + + @DisplayName("보고를 켰는데 info 로그가 닫혀 있으면 그 사실을 알린다") + @Test + void runShouldWarnWhenReportIsInvisible() { + // given - 테스트 설정에서 root 를 warn 으로 두는 프로젝트가 흔하다 + Level original = logger.getLevel(); + logger.setLevel(Level.WARN); + try { + recordQueries(5); + QueryCountContext.requestQueryLimit(QueryLimit.of(0, true)); + + // when + QueryLimitWatch.run(); + + // then - 켰는데 아무것도 안 보이는 상태를 그대로 두지 않는다 + assertThat(appender.list) + .filteredOn(event -> event.getLevel() == Level.WARN) + .isNotEmpty() + .allSatisfy(event -> assertThat(event.getFormattedMessage()) + .contains("logging.level.soon.springtestutil=info")); + } + finally { + logger.setLevel(original); + } + } + + @DisplayName("보고와 상한을 함께 켜면 보고한 뒤 실패시킨다") + @Test + void runShouldReportBeforeFailing() { + // given + recordQueries(12); + QueryCountContext.requestQueryLimit(QueryLimit.of(10, true)); + + // when, then + assertThatThrownBy(QueryLimitWatch::run).isInstanceOf(AssertionError.class); + assertThat(appender.list).hasSize(1); + } + +}