Skip to content

Commit 73c8f3d

Browse files
authored
Merge pull request #20205 from Napalys/java/mocking-all-non-private-methods-means-unit-test-is-too-big
Java: port quality query `java/mocking-all-non-private-methods-means-unit-test-is-too-big`
2 parents 7ef2b01 + 1949d9f commit 73c8f3d

30 files changed

+1350
-0
lines changed

java/ql/integration-tests/java/query-suite/java-code-quality-extended.qls.expected

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ ql/java/ql/src/Violations of Best Practice/Naming Conventions/LocalShadowsFieldC
8080
ql/java/ql/src/Violations of Best Practice/Naming Conventions/SameNameAsSuper.ql
8181
ql/java/ql/src/Violations of Best Practice/Records/IgnoredSerializationMembersOfRecordClass.ql
8282
ql/java/ql/src/Violations of Best Practice/SpecialCharactersInLiterals/NonExplicitControlAndWhitespaceCharsInLiterals.ql
83+
ql/java/ql/src/Violations of Best Practice/Testing/ExcessivePublicMethodMocking.ql
8384
ql/java/ql/src/Violations of Best Practice/Undesirable Calls/CallsToRunFinalizersOnExit.ql
8485
ql/java/ql/src/Violations of Best Practice/Undesirable Calls/CallsToStringToString.ql
8586
ql/java/ql/src/Violations of Best Practice/Undesirable Calls/CallsToSystemExit.ql

java/ql/integration-tests/java/query-suite/java-code-quality.qls.expected

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ ql/java/ql/src/Violations of Best Practice/Naming Conventions/LocalShadowsFieldC
7878
ql/java/ql/src/Violations of Best Practice/Naming Conventions/SameNameAsSuper.ql
7979
ql/java/ql/src/Violations of Best Practice/Records/IgnoredSerializationMembersOfRecordClass.ql
8080
ql/java/ql/src/Violations of Best Practice/SpecialCharactersInLiterals/NonExplicitControlAndWhitespaceCharsInLiterals.ql
81+
ql/java/ql/src/Violations of Best Practice/Testing/ExcessivePublicMethodMocking.ql
8182
ql/java/ql/src/Violations of Best Practice/Undesirable Calls/CallsToStringToString.ql
8283
ql/java/ql/src/Violations of Best Practice/Undesirable Calls/DefaultToString.ql
8384
ql/java/ql/src/Violations of Best Practice/Undesirable Calls/DoNotCallFinalize.ql
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
## Overview
2+
3+
Mocking methods of a class is necessary for unit tests to run without overhead caused by expensive I/O operations. However, when a unit test ends up mocking all public methods of a class, it may indicate that the test is too complicated, possibly because it is trying to test multiple things at once. Such extensive mocking is likely a signal that the scope of the unit test is reaching beyond a single unit of functionality.
4+
5+
## Recommendation
6+
7+
It is best to contain the scope of a single unit test to a single unit of functionality. For example, a unit test may aim to test a series of data-transforming functions that depend on an ORM class. Even though the functions may be semantically related with one another, it is better to create a unit test for each function.
8+
9+
## Example
10+
11+
The following example mocks all methods of an ORM class named `EmployeeRecord`, and tests four functions against them. Since the scope of the unit test harbors all four of them, all of the methods provided by the class are mocked.
12+
13+
```java
14+
public class EmployeeRecord {
15+
public int add(Employee employee) { ... }
16+
17+
public Employee get(String name) { ... }
18+
19+
public int update(Employee employee, String newName) { ... }
20+
21+
public int delete(Employee employee) { ... }
22+
}
23+
24+
public class TestORM {
25+
@Test
26+
public void nonCompliant() {
27+
Employee sampleEmployee = new Employee("John Doe");
28+
EmployeeRecord employeeRecordMock = mock(EmployeeRecord.class); // NON_COMPLIANT: Mocked class has all of its public methods used in the test
29+
when(employeeRecordMock.add(sampleEmployee)).thenReturn(0); // Mocked EmployeeRecord.add
30+
when(employeeRecordMock.get("John Doe")).thenReturn(sampleEmployee); // Mocked EmployeeRecord.get
31+
when(employeeRecordMock.update(sampleEmployee, "Jane Doe")).thenReturn(0); // Mocked EmployeeRecord.update
32+
when(employeeRecordMock.delete(sampleEmployee)).thenReturn(0); // Mocked EmployeeRecord.delete
33+
}
34+
35+
@Test
36+
public void compliant() {
37+
Employee sampleEmployee = new Employee("John Doe");
38+
EmployeeRecord employeeRecordMock = mock(EmployeeRecord.class); // COMPLIANT: Only some of the public methods belonging to the mocked object are used
39+
when(employeeRecordMock.add(sampleEmployee)).thenReturn(0); // Mocked EmployeeRecord.add
40+
when(employeeRecordMock.update(sampleEmployee, "Jane Doe")).thenReturn(0); // Mocked EmployeeRecord.update
41+
}
42+
43+
}
44+
```
45+
46+
## Implementation Notes
47+
48+
JUnit provides two different ways of mocking a method call: `when(mockedObject.methodToMock(...)).thenReturn(...)` and `doReturn(...).when(mockedObject).methodToMock(...)`. Both forms are taken into account by the query.
49+
50+
## References
51+
52+
- Baeldung: [Best Practices for Unit Testing in Java](https://www.baeldung.com/java-unit-testing-best-practices).
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
/**
2+
* @id java/excessive-public-method-mocking
3+
* @previous-id java/mocking-all-non-private-methods-means-unit-test-is-too-big
4+
* @name Mocking all public methods of a class may indicate the unit test is testing too much
5+
* @description Mocking all public methods provided by a class might indicate the unit test
6+
* aims to test too many things.
7+
* @kind problem
8+
* @precision high
9+
* @problem.severity recommendation
10+
* @tags quality
11+
* maintainability
12+
* readability
13+
*/
14+
15+
import java
16+
17+
/**
18+
* A call to Mockito's `mock` method.
19+
*/
20+
class MockitoMockCall extends MethodCall {
21+
MockitoMockCall() { this.getMethod().hasQualifiedName("org.mockito", "Mockito", "mock") }
22+
23+
/**
24+
* Gets the type that this call intends to mock. For example:
25+
* ```java
26+
* EmployeeRecord employeeRecordMock = mock(EmployeeRecord.class);
27+
* ```
28+
* This predicate gets the class `EmployeeRecord` in the above example.
29+
*/
30+
Type getMockedType() { result = this.getAnArgument().(TypeLiteral).getReferencedType() }
31+
}
32+
33+
/**
34+
* A method call that mocks a target method in a JUnit test. For example:
35+
* ```java
36+
* EmployeeRecord employeeRecordMock = mock(EmployeeRecord.class);
37+
* when(employeeRecordMock.add(sampleEmployee)).thenReturn(0); // Mocked EmployeeRecord.add
38+
* doReturn(0).when(employeeRecordMock).add(sampleEmployee); // Mocked EmployeeRecord.add
39+
* ```
40+
* This class captures the call to `add` which mocks the equivalent method of the class `EmployeeRecord`.
41+
*/
42+
class MockitoMockingMethodCall extends MethodCall {
43+
MockitoMockCall mockCall;
44+
45+
MockitoMockingMethodCall() {
46+
/* 1. The qualifier originates from the mock call. */
47+
this.getQualifier().getControlFlowNode().getAPredecessor+() = mockCall.getControlFlowNode() and
48+
/* 2. The mocked method can be found in the class being mocked with the mock call. */
49+
mockCall.getMockedType().(ClassOrInterface).getAMethod() = this.getMethod()
50+
}
51+
52+
/**
53+
* Gets the call to Mockito's `mock` from which the qualifier, the mocked object, originates.
54+
*/
55+
MockitoMockCall getMockitoMockCall() { result = mockCall }
56+
}
57+
58+
/*
59+
* The following from-where-select embodies this pseudocode:
60+
* - Find a JUnit4TestMethod which:
61+
* - for a class that it mocks with a call to `mock`,
62+
* - for all methods that the class has, there is a method that this test method mocks.
63+
*/
64+
65+
from JUnit4TestMethod testMethod, ClassOrInterface mockedClassOrInterface
66+
where
67+
exists(MockitoMockCall mockCall |
68+
mockCall.getEnclosingCallable() = testMethod and
69+
mockedClassOrInterface = mockCall.getMockedType() and
70+
// Only flag classes with multiple public methods (2 or more)
71+
strictcount(Method m | m = mockedClassOrInterface.getAMethod() and m.isPublic()) > 1 and
72+
forex(Method method | method = mockedClassOrInterface.getAMethod() and method.isPublic() |
73+
exists(MockitoMockingMethodCall mockedMethod |
74+
mockedMethod.getMockitoMockCall() = mockCall and
75+
mockedMethod.getMethod() = method
76+
)
77+
)
78+
)
79+
select testMethod, "This test method mocks all public methods of a $@.", mockedClassOrInterface,
80+
"class or an interface"
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
/**
2+
* Underlying data type of the ORM class and functions.
3+
*/
4+
public class Employee {
5+
Employee(String name) {
6+
this.name = name;
7+
}
8+
9+
String name;
10+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
/**
2+
* Sample ORM class for the type `Employee`.
3+
*/
4+
public class EmployeeRecord {
5+
public int add(Employee employee) {
6+
return 1;
7+
}
8+
9+
public Employee get(String name) {
10+
return new Employee("Sample");
11+
}
12+
13+
public int update(Employee employee, String newName) {
14+
return 1;
15+
}
16+
17+
public int delete(Employee employee) {
18+
return 1;
19+
}
20+
21+
private void f() { }
22+
23+
private void g() { }
24+
25+
private void h() { }
26+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
/**
2+
* Simple class with a single public method to test the edge case.
3+
* When this single method is mocked, it means ALL public methods are mocked.
4+
*/
5+
public class EmployeeStatus {
6+
public String getStatus() {
7+
return "active";
8+
}
9+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
| TestORM.java:34:15:34:27 | nonCompliant1 | This test method mocks all public methods of a $@. | EmployeeRecord.java:4:14:4:27 | EmployeeRecord | class or an interface |
2+
| TestORM.java:47:15:47:27 | nonCompliant2 | This test method mocks all public methods of a $@. | EmployeeRecord.java:4:14:4:27 | EmployeeRecord | class or an interface |
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
query: Violations of Best Practice/Testing/ExcessivePublicMethodMocking.ql
2+
postprocess: utils/test/InlineExpectationsTestQuery.ql
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import org.junit.Test;
2+
import static org.mockito.Mockito.mock;
3+
import static org.mockito.Mockito.when;
4+
import static org.mockito.Mockito.doReturn;
5+
6+
public class TestORM {
7+
/**
8+
* Test of form `when(mockedObject.methodToBeMocked()).thenReturn(someVal)`.
9+
*/
10+
@Test
11+
public void compliant1() {
12+
Employee sampleEmployee = new Employee("John Doe");
13+
EmployeeRecord employeeRecordMock = mock(EmployeeRecord.class); // COMPLIANT: Only some of the public methods belonging to the mocked object are used
14+
when(employeeRecordMock.add(sampleEmployee)).thenReturn(0); // Mocked EmployeeRecord.add
15+
when(employeeRecordMock.update(sampleEmployee, "Jane Doe")).thenReturn(0); // Mocked EmployeeRecord.update
16+
}
17+
18+
/**
19+
* Test of form `doReturn(someVal).when(mockedObject).methodToBeMocked()`.
20+
*/
21+
@Test
22+
public void compliant2() {
23+
Employee sampleEmployee = new Employee("John Doe");
24+
EmployeeRecord employeeRecordMock = mock(EmployeeRecord.class); // COMPLIANT: Only some of the public methods belonging to the mocked object are used
25+
doReturn(0).when(employeeRecordMock).add(sampleEmployee); // Mocked EmployeeRecord.add
26+
doReturn(0).when(employeeRecordMock).get("John Doe"); // Mocked EmployeeRecord.get
27+
doReturn(0).when(employeeRecordMock).delete(sampleEmployee); // Mocked EmployeeRecord.delete
28+
}
29+
30+
/**
31+
* Test of form `when(mockedObject.methodToBeMocked()).thenReturn(someVal)`.
32+
*/
33+
@Test
34+
public void nonCompliant1() { // $ Alert
35+
Employee sampleEmployee = new Employee("John Doe");
36+
EmployeeRecord employeeRecordMock = mock(EmployeeRecord.class); // NON_COMPLIANT: All public methods of the mocked object are used
37+
when(employeeRecordMock.add(sampleEmployee)).thenReturn(0); // Mocked EmployeeRecord.add
38+
when(employeeRecordMock.get("John Doe")).thenReturn(sampleEmployee); // Mocked EmployeeRecord.get
39+
when(employeeRecordMock.update(sampleEmployee, "Jane Doe")).thenReturn(0); // Mocked EmployeeRecord.update
40+
when(employeeRecordMock.delete(sampleEmployee)).thenReturn(0); // Mocked EmployeeRecord.delete
41+
}
42+
43+
/**
44+
* Test of form `doReturn(someVal).when(mockedObject).methodToBeMocked()`.
45+
*/
46+
@Test
47+
public void nonCompliant2() { // $ Alert
48+
Employee sampleEmployee = new Employee("John Doe");
49+
EmployeeRecord employeeRecordMock = mock(EmployeeRecord.class); // NON_COMPLIANT: All public methods of the mocked object are used
50+
doReturn(0).when(employeeRecordMock).add(sampleEmployee); // Mocked EmployeeRecord.add
51+
doReturn(0).when(employeeRecordMock).get("John Doe"); // Mocked EmployeeRecord.get
52+
doReturn(0).when(employeeRecordMock).update(sampleEmployee, "Jane Doe"); // Mocked EmployeeRecord.update
53+
doReturn(0).when(employeeRecordMock).delete(sampleEmployee); // Mocked EmployeeRecord.delete
54+
}
55+
56+
/**
57+
* Edge case: Class with single public method - should NOT be flagged.
58+
* When there's only one public method, mocking it doesn't indicate a "too big" test.
59+
*/
60+
@Test
61+
public void compliantSingleMethod() {
62+
EmployeeStatus statusMock = mock(EmployeeStatus.class); // COMPLIANT: Single public method, no choice but to mock it if needed
63+
when(statusMock.getStatus()).thenReturn("inactive"); // Mocked EmployeeStatus.getStatus (the only public method, but that's OK)
64+
}
65+
}

0 commit comments

Comments
 (0)