Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
43 changes: 43 additions & 0 deletions README.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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 요청을 보내는 테스트는 쿼리가 서버 쪽 워커 스레드에서 나갑니다. 기록은 스레드마다
Expand Down
44 changes: 44 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down
8 changes: 7 additions & 1 deletion src/main/java/soon/springtestutil/config/AutoConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -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 자동 설정입니다.
Expand Down Expand Up @@ -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)
);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -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();
}

Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@
* fail: false
* other-threads:
* enabled: false
* max-queries:
* per-test: 0
* report: false
* }</pre>
*/
@ConfigurationProperties(prefix = "query-counter")
Expand All @@ -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;
}
Expand All @@ -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.
*
* <p>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.
*
* <p>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.
*
Expand Down
37 changes: 37 additions & 0 deletions src/main/java/soon/springtestutil/querycount/QueryLimit.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package soon.springtestutil.querycount;

/**
* How many queries one test may run, and whether to report what it ran.
*
* <p>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.
*
* <p>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;
}

}
Loading
Loading