Java vs Go: Impressive solutions for invented problems
Let’s start with the Shadoks
Les Shadoks is a cartoon created by Jacques Rouxel which was first broadcast in France between 1968 and 1974.
Les Shadoks series had a lasting cultural impact, and introduced some lovely phrases/expressions that, to this day, are used in the day to day discussions:
When one tries continuously, one ends up succeeding. Thus, the more one fails, the greater the chance that it will work
or:
Every advantage has its disadvantages and vice versa
or even:
If there is no solution, it is because there is no problem.
But most importantly:
Why do it the easy way when you can do it the hard way?
Why the Shadoks reference ?
I’ve been writing Java professionally for the last 13 years. Luckily, I also work with other languages, mostly Go and Javascript.
Working with Go made me notice a pattern in the Java ecosystem: it is a wide and rich one, with very solid & technically impressive libraries & frameworks.
But the thing is, many of those libraries & frameworks are impressive solutions for a problem which didn’t need exist in the first place: trying to use a declarative approach everywhere.
Case in hand: JUnit tests
In the following, I’ll be using a (unit) test written in Java and showcasing JUnit, which is arguably the most popular and used testing framework in the Java land, in its latest version at the time this post was written, version 5 codenamed Jupiter.
There will be a couple of code snippets, so please bear with me till I make my point at the end.
In java, everything is a class
The same applies to tests:
import org.junit.jupiter.api.Test;
public class ExampleTest {
@Test
void test_login() {
assertThat(login("username", "p@$$w0rd")).isTrue();
}
}
As can be seen in the code snippet above, a test is:
- a class
- with a method
- annotated with
@Test
to tell JUnit that this is the test code we’d like to exercise.
Test cases
What if we’d like to test our login
logic against multiple username
and password
combinations ?
One way to do it would be to create a new test method for every combination:
import org.junit.jupiter.api.Test;
public class ExampleTest {
@Test
void accepts_correct_login1() {
assertThat(
login("username", "p@$$w0rd")
).isTrue();
}
@Test
void rejects_incorrect_username() {
assertThat(
login("incorrect-username", "p@$$w0rd")
).isFalse();
}
@Test
void rejects_incorrect_password() {
assertThat(
login("username", "incorrect-password")
).isFalse();
}
}
This quickly gets unwieldy when:
- The test method body is long, forcing us to duplicate it
- There are a lot of test cases, e.g. dozens of username/password combinations for example
- Dynamically generated test cases, e.g. random values, fuzzing, …
Parameterized tests
That’s why JUnit offers a way to write the test method only once, and invoke it as many times as we provide test cases:
import org.junit.jupiter.api.Test;
public class ExampleTest {
@ParameterizedTest
@MethodSource("loginTestCases")
void test_login(String username, String password, boolean expected) {
assertThat(
login(username, password)
).isEqualTo(expected);
}
private static Stream<Arguments> loginTestCases() {
return Stream.of(
Arguments.of("username", "p@$$w0rd", true),
Arguments.of("incorrect-username", "p@$$w0rd", false),
Arguments.of("username", "incorrect-p@$$w0rd", false)
);
}
}
Lots of noise, but basically:
loginTestCases
returns the list of test casestest_login
is a parametrized test method that gets fed the different combinations
Test case naming
In the testing report, and by default, parameterized test methods will get a dynamically generated name which is the combination of all the parameters it receives.
This can be tweaked using a templated string:
@ParameterizedTest(name = "login({0}, {1}) => {2}")
Where {0}
, {1}
, … get replaced by the method argument in the corresponding position.
Execution order
Say we have 2 cases that must run in a specific order, e.g.:
- first test method logs the user in and obtains a token
- second test method uses that token to test the system further
The code would look like this:
public class ExampleTest {
private String token;
@Test
void testLogin() {
String token = login("username", "p@$$w0rd")
assertThat(token).isNotNull();
this.token = token;
}
@Test
void testApiCall() {
int res = apiCall(this.token);
assertThat(res).isEqualTo(42);
}
}
Except this won’t work as expected:
- JUnit doesn’t guarantee the test method execution order
- JUnit doesn’t guarantee that the same test instance would be reused between the 2 test methods (needed to share the
token
field)
But not to worry: more annotations to the rescue:
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
public class ExampleTest {
private String token;
@Test
@Order(1)
void testLogin() {
String token = login("username", "p@$$w0rd")
assertThat(token).isNotNull();
this.token = token;
}
@Test
@Order(2)
void testApiCall() {
int res = apiCall(this.token);
assertThat(res).isEqualTo(42);
}
}
Conditional tests
What if we want to only run a specific test method when a certain condition is set ?
Say for example we have a slow test we would like to only run on a beefy CI agent, based on E2E
env var for example.
@Test
@EnabledIfEnvironmentVariable(named = "E2E", matches = "true")
void testSlowApiCall() {
int res = apiCall(this.token);
assertThat(res).isEqualTo(42);
}
What’s the Go way of doing the same ?
Test cases
Go has:
- slices/arrays
- for loops
func TestLogin(t *testing.T) {
type Case struct {
Username string
Password string
Expected bool
}
for _, tt := range []Case{
{"username", "p@$$w0rd", true},
{"incorrect-username", "p@$$w0rd", false},
{"username", "incorrect-p@$$w0rd", false},
} {
t.Run(fmt.Sprintf("login(%s, %s) => %v", tt.Username, tt.Password, tt.Expected), func(t *testing.T) {
require.Equal(t, tt.Expected, login(tt.Username, tt.Password))
})
}
}
I make use of testify toolkit to simplify assertions.
Order, keeping state
Go executes instructions from top to bottom. Unless you exit the current scope, the variables keep their values.
func TestLoginAPICall(t *testing.T) {
token := ""
t.Run("login", func(t *testing.T) {
token = login("username", "p@$$w0rd")
require.NotEmpty(t, token)
})
t.Run("api call", func(t *testing.T) {
res := apiCall(token)
require.Equal(t, 42, res)
})
}
Conditional execution
Go has if
switch:
func TestLogin(t *testing.T) {
token := ""
t.Run("login", func(t *testing.T) {
token = login("username", "p@$$w0rd")
require.NotEmpty(t, token)
})
if os.Getenv("E2E")!="" {
t.Run("slow api call", func(t *testing.T) {
res := slowApiCall(token)
require.Equal(t, 42, res)
})
}
}
Closing words
Something went very wrong in the Java ecosystem:
for some reason, we as a community, collectively decided that:
- we should only be using the language’s constructs (
if
,for
, …) in the business/functional code - for anything else, e.g. tests, configuration, frameworks, etc., we instead have to invent a half-assed declarative language, preferably annotation based
It is most impressive that we got so far with these self-imposed limitations.
I am not picking on JUnit. On the opposite: what they are able to achieve is simply impressive. Everything is very extensible and configurable, and it must have taken lots of time & effort to reach this point.
Yet, the Go testing library achieves the same level of power/flexibility while being much simpler and with a tinier surface area simply by choosing an imperative model (t.Run()
vs @Test
), making it possible to use the full power of the host language.
Thoughts ?
Disqus went to shit. So I got rid of it in this reincarnation.
I’m looking into other options (utteranc.es, commeto.io, remark42, …)
In the meantime, please feel free to yell at me in twitter for example:
A new (controversial?) blog post: Technically impressive solutions for invented problems https://t.co/vZvbVemmgD
— Jawher Moussa (@jawher) June 18, 2020