Skip to content

Commit b0ac880

Browse files
authored
Merge pull request #446 from gsmet/mocking-utils-1.x
Add a few mocking utils to the testing framework + add more documentation - 1.x
2 parents da0a6ef + cef91b4 commit b0ac880

File tree

3 files changed

+165
-32
lines changed

3 files changed

+165
-32
lines changed

docs/modules/ROOT/pages/testing.adoc

Lines changed: 57 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -86,9 +86,9 @@ See the `GitHubMockContext` interface for a detailed list of methods.
8686

8787
Just run `./mvnw test` from the commandline.
8888

89-
== Mockito features
89+
== More advanced Mockito features
9090

91-
You can use most Mockito features on the GitHub object mocks;
91+
You can use most https://javadoc.io/doc/org.mockito/mockito-core/latest/org/mockito/Mockito.html[Mockito features] on the GitHub object mocks;
9292
that includes defining their behavior before the event is simulated:
9393

9494
[source, java]
@@ -100,23 +100,70 @@ class CreateCommentTest {
100100
void testIssueOpened() throws IOException {
101101
GitHubAppTesting.given() // <1>
102102
.github(mocks -> { // <2>
103-
Mockito.doThrow(new RuntimeException()) // <3>
103+
Mockito.doThrow(new RuntimeException("Simulated exception")) // <3>
104104
.when(mocks.issue(750705278))
105105
.comment(Mockito.any());
106106
})
107107
.when().payloadFromClasspath("/issue-opened.json")
108108
.event(GHEvent.ISSUES)
109-
.then().github(mocks -> {
110-
Mockito.verify(mocks.issue(750705278))
111-
.comment("Hello from my GitHub App");
109+
.then().github(mocks -> { // <4>
110+
Mockito.verify(mocks.issue(750705278)) // <5>
111+
.createReaction(ReactionContent.CONFUSED);
112112
});
113113
}
114114
}
115115
----
116116
<1> Use given().github(...) to configure mocks.
117117
<2> The given `mocks` object gives access to mocks of GitHub objects, indexed by their identifier,
118118
just like in `.then().github(...)`.
119+
This can be used to configure the behavior of objects referenced in the event payload,
120+
such as (here) the `GHIssue`.
119121
<3> Here we're configuring the mock to throw an exception when the application tries to comment on the issue.
122+
<4> We can still use `.then().github(mocks -> ...)` to perform assertions on GitHub objects involved in the event handling.
123+
<5> Here we're verifying that the application caught the runtime exception and added a `confused` reaction to the GitHub issue.
124+
125+
You can also use the class `GitHubAppMockito` to simplify mocking for some common scenarios:
126+
127+
[source, java]
128+
----
129+
@QuarkusTest
130+
@GitHubAppTest
131+
class CreateCommentTest {
132+
@Test
133+
void testIssueEdited() throws IOException {
134+
var queryCommentsBuilder = GitHubAppMockito.mockBuilder(GHIssueCommentQueryBuilder.class); // <1>
135+
GitHubAppTesting.given()
136+
.github(mocks -> {
137+
Mockito.when(mocks.issue(750705278).queryComments())
138+
.thenReturn(queryCommentsBuilder);
139+
var previousCommentFromBotMock = mocks.ghObject(GHIssueComment.class, 2);
140+
var commentsMock = GitHubAppMockito.mockPagedIterable(previousCommentFromBotMock); // <2>
141+
Mockito.when(queryCommentsBuilder.list()) // <3>
142+
.thenReturn(commentsMock);
143+
})
144+
.when().payloadFromClasspath("/issue-edited.json")
145+
.event(GHEvent.ISSUES)
146+
.then().github(mocks -> {
147+
Mockito.verify(mocks.issue(750705278)).queryComments();
148+
// The bot already commented , it should not comment again.
149+
Mockito.verifyNoMoreInteractions(mocks.issue(750705278));
150+
});
151+
}
152+
}
153+
----
154+
<1> Use `GitHubAppMockito.mockBuilder` to easily mock builders from the GitHub API.
155+
It will mock the builder by setting its default answer to just `return this;`,
156+
which is convenient since most methods in builders do that.
157+
+
158+
Here we're mocking the builder returned by `GHIssue#queryComments()`.
159+
<2> Use `GitHubAppMockito.mockPagedIterable` to easily mock `PagedIterable` from the GitHub API,
160+
which is the return type from many query or listing methods.
161+
+
162+
Here we're mocking the list of comments returned when querying issue comments,
163+
so that it includes exactly one (mocked) comment.
164+
<3> When mocking builders, don't forget to define the behavior of the "build method" (`list()`/`create()`/...),
165+
because the default answer set by `GitHubAppMockito.mockBuilder` (returning `this`)
166+
will not work for that method.
120167

121168
== Mocking the configuration file
122169

@@ -132,10 +179,8 @@ class CreateCommentTest {
132179
void testIssueOpened() throws IOException {
133180
GitHubAppTesting.given() // <1>
134181
.github(mocks -> { // <2>
135-
mocks.configFileFromString( // <3>
136-
"my-bot.yml",
137-
"greeting.message: \"some custom message\""
138-
);
182+
mocks.configFile("my-bot.yml") // <3>
183+
.fromString("greeting.message: \"some custom message\"");
139184
})
140185
.when()
141186
.payloadFromClasspath("/issue-opened.json")
@@ -164,10 +209,8 @@ class CreateCommentTest {
164209
void testIssueOpened() throws IOException {
165210
GitHubAppTesting.given() // <1>
166211
.github(mocks -> { // <2>
167-
mocks.configFileFromClasspath( // <3>
168-
"my-bot.yml",
169-
"/my-bot-some-custom-message.yml"
170-
);
212+
mocks.configFile("my-bot.yml") // <3>
213+
.fromClasspath("/my-bot-some-custom-message.yml");
171214
})
172215
.when()
173216
.payloadFromClasspath("/issue-opened.json")

integration-tests/testing-framework/src/test/java/io/quarkiverse/githubapp/it/testingframework/TestingFrameworkTest.java

Lines changed: 92 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package io.quarkiverse.githubapp.it.testingframework;
22

3+
import static io.quarkiverse.githubapp.testing.GitHubAppMockito.mockBuilder;
4+
import static io.quarkiverse.githubapp.testing.GitHubAppMockito.mockPagedIterable;
35
import static io.quarkiverse.githubapp.testing.GitHubAppTesting.given;
46
import static io.quarkiverse.githubapp.testing.GitHubAppTesting.when;
57
import static org.assertj.core.api.Assertions.assertThat;
@@ -20,6 +22,8 @@
2022
import org.kohsuke.github.GHApp;
2123
import org.kohsuke.github.GHAppInstallation;
2224
import org.kohsuke.github.GHEvent;
25+
import org.kohsuke.github.GHIssueComment;
26+
import org.kohsuke.github.GHIssueCommentQueryBuilder;
2327
import org.kohsuke.github.GHPullRequest;
2428
import org.kohsuke.github.GHRepository;
2529
import org.kohsuke.github.GitHub;
@@ -55,36 +59,111 @@ void ghObjectMocking() {
5559
Mockito.when(mocks.issue(750705278).getBody()).thenReturn("someValue");
5660
})
5761
.when().payloadFromClasspath("/issue-opened.json")
58-
.event(GHEvent.ISSUES)
59-
.then().github(mocks -> {
60-
}))
62+
.event(GHEvent.ISSUES))
6163
.doesNotThrowAnyException();
6264
assertThat(capture[0]).isEqualTo("someValue");
6365
}
6466

6567
@Test
6668
void ghObjectVerify() {
67-
ThrowingCallable assertion = () -> when().payloadFromClasspath("/issue-opened.json")
69+
// Do not change this, the documentation includes the exact same code
70+
ThrowingCallable assertion = () -> when()
71+
.payloadFromClasspath("/issue-opened.json")
6872
.event(GHEvent.ISSUES)
69-
.then().github(mocks -> {
70-
verify(mocks.issue(750705278))
71-
.addLabels("someValue");
73+
.then().github(mocks -> { // <4>
74+
Mockito.verify(mocks.issue(750705278))
75+
.comment("Hello from my GitHub App");
7276
});
7377

7478
// Success
7579
IssueEventListener.behavior = (payload, configFile) -> {
76-
payload.getIssue().addLabels("someValue");
80+
payload.getIssue().comment("Hello from my GitHub App");
7781
};
7882
assertThatCode(assertion).doesNotThrowAnyException();
7983

8084
// Failure
8185
IssueEventListener.behavior = (payload, configFile) -> {
82-
payload.getIssue().addLabels("otherValue");
86+
payload.getIssue().comment("otherValue");
8387
};
8488
assertThatThrownBy(assertion)
8589
.isInstanceOf(AssertionError.class)
8690
.hasMessageContaining("Actual invocations have different arguments:\n" +
87-
"GHIssue#750705278.addLabels(\"otherValue\");");
91+
"GHIssue#750705278.comment(\n \"otherValue\"\n);");
92+
}
93+
94+
@Test
95+
void ghObjectMockAndVerify() {
96+
// Do not change this, the documentation includes the exact same code
97+
ThrowingCallable assertion = () -> given()
98+
.github(mocks -> {
99+
Mockito.doThrow(new RuntimeException("Simulated exception"))
100+
.when(mocks.issue(750705278))
101+
.comment(Mockito.any());
102+
})
103+
.when().payloadFromClasspath("/issue-opened.json")
104+
.event(GHEvent.ISSUES)
105+
.then().github(mocks -> {
106+
Mockito.verify(mocks.issue(750705278))
107+
.createReaction(ReactionContent.CONFUSED);
108+
});
109+
110+
// Success
111+
IssueEventListener.behavior = (payload, configFile) -> {
112+
try {
113+
payload.getIssue().comment("Hello from my GitHub App");
114+
} catch (RuntimeException e) {
115+
payload.getIssue().createReaction(ReactionContent.CONFUSED);
116+
}
117+
};
118+
assertThatCode(assertion).doesNotThrowAnyException();
119+
120+
// Failure
121+
IssueEventListener.behavior = (payload, configFile) -> {
122+
payload.getIssue().comment("Hello from my GitHub App");
123+
};
124+
assertThatThrownBy(assertion)
125+
.isInstanceOf(AssertionError.class)
126+
.hasMessageContaining("The event handler threw an exception: Simulated exception");
127+
}
128+
129+
@Test
130+
void ghAppMockUtils() {
131+
// Do not change this, the documentation includes the exact same code
132+
var queryCommentsBuilder = mockBuilder(GHIssueCommentQueryBuilder.class);
133+
ThrowingCallable assertion = () -> given()
134+
.github(mocks -> {
135+
Mockito.when(mocks.issue(750705278).queryComments())
136+
.thenReturn(queryCommentsBuilder);
137+
var previousCommentFromBotMock = mocks.ghObject(GHIssueComment.class, 2);
138+
var commentsMock = mockPagedIterable(previousCommentFromBotMock);
139+
Mockito.when(queryCommentsBuilder.list())
140+
.thenReturn(commentsMock);
141+
})
142+
.when().payloadFromClasspath("/issue-opened.json")
143+
.event(GHEvent.ISSUES)
144+
.then().github(mocks -> {
145+
Mockito.verify(mocks.issue(750705278)).queryComments();
146+
// The bot already commented, it should not comment again.
147+
Mockito.verifyNoMoreInteractions(mocks.issue(750705278));
148+
});
149+
150+
// Success
151+
IssueEventListener.behavior = (payload, configFile) -> {
152+
if (!payload.getIssue().queryComments().list().iterator().hasNext()) {
153+
payload.getIssue().comment("Hello from my GitHub App");
154+
}
155+
};
156+
assertThatCode(assertion).doesNotThrowAnyException();
157+
158+
// Failure
159+
IssueEventListener.behavior = (payload, configFile) -> {
160+
boolean ignored = !payload.getIssue().queryComments().list().iterator().hasNext();
161+
// Comment regardless
162+
payload.getIssue().comment("Hello from my GitHub App");
163+
};
164+
assertThatThrownBy(assertion)
165+
.isInstanceOf(AssertionError.class)
166+
.hasMessageContaining("No interactions wanted here");
88167
}
89168

90169
@Test
@@ -276,18 +355,17 @@ void clientProvider() {
276355
Mockito.when(mocks.applicationClient().getApp()).thenReturn(app);
277356
Mockito.when(installation1.getId()).thenReturn(1L);
278357
Mockito.when(installation2.getId()).thenReturn(2L);
279-
PagedIterable<GHAppInstallation> appInstallations = MockHelper.mockPagedIterable(installation1,
280-
installation2);
358+
PagedIterable<GHAppInstallation> appInstallations = mockPagedIterable(installation1, installation2);
281359
Mockito.when(app.listInstallations()).thenReturn(appInstallations);
282360

283361
Mockito.when(installation1Repo1.getFullName()).thenReturn("quarkusio/quarkus");
284-
PagedSearchIterable<GHRepository> installation1Repos = MockHelper.mockPagedIterable(installation1Repo1);
362+
PagedSearchIterable<GHRepository> installation1Repos = mockPagedIterable(installation1Repo1);
285363
Mockito.when(installation1.listRepositories())
286364
.thenReturn(installation1Repos);
287365

288366
Mockito.when(installation2Repo1.getFullName()).thenReturn("quarkiverse/quarkus-github-app");
289367
Mockito.when(installation2Repo2.getFullName()).thenReturn("quarkiverse/quarkus-github-api");
290-
PagedSearchIterable<GHRepository> installation2Repos = MockHelper.mockPagedIterable(installation2Repo1,
368+
PagedSearchIterable<GHRepository> installation2Repos = mockPagedIterable(installation2Repo1,
291369
installation2Repo2);
292370
Mockito.when(installation2.listRepositories())
293371
.thenReturn(installation2Repos);

integration-tests/testing-framework/src/test/java/io/quarkiverse/githubapp/it/testingframework/MockHelper.java renamed to testing/src/main/java/io/quarkiverse/githubapp/testing/GitHubAppMockito.java

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,34 @@
1-
package io.quarkiverse.githubapp.it.testingframework;
1+
package io.quarkiverse.githubapp.testing;
22

33
import static org.mockito.Mockito.mock;
44
import static org.mockito.Mockito.when;
5+
import static org.mockito.Mockito.withSettings;
56

67
import java.util.Iterator;
78
import java.util.List;
89

910
import org.kohsuke.github.PagedIterator;
1011
import org.kohsuke.github.PagedSearchIterable;
12+
import org.mockito.Answers;
13+
import org.mockito.quality.Strictness;
1114

12-
class MockHelper {
15+
public final class GitHubAppMockito {
16+
17+
private GitHubAppMockito() {
18+
}
19+
20+
public static <T> T mockBuilder(Class<T> builderClass) {
21+
return mock(builderClass, withSettings().defaultAnswer(Answers.RETURNS_SELF));
22+
}
1323

1424
@SafeVarargs
1525
@SuppressWarnings("unchecked")
1626
public static <T> PagedSearchIterable<T> mockPagedIterable(T... contentMocks) {
17-
PagedSearchIterable<T> iterableMock = mock(PagedSearchIterable.class);
27+
PagedSearchIterable<T> iterableMock = mock(PagedSearchIterable.class,
28+
withSettings().stubOnly().strictness(Strictness.LENIENT).defaultAnswer(Answers.RETURNS_SELF));
29+
when(iterableMock.spliterator()).thenAnswer(ignored -> List.of(contentMocks).spliterator());
1830
when(iterableMock.iterator()).thenAnswer(ignored -> {
19-
PagedIterator<T> iteratorMock = mock(PagedIterator.class);
31+
PagedIterator<T> iteratorMock = mock(PagedIterator.class, withSettings().stubOnly().strictness(Strictness.LENIENT));
2032
Iterator<T> actualIterator = List.of(contentMocks).iterator();
2133
when(iteratorMock.next()).thenAnswer(ignored2 -> actualIterator.next());
2234
when(iteratorMock.hasNext()).thenAnswer(ignored2 -> actualIterator.hasNext());

0 commit comments

Comments
 (0)