DEV Community

Cover image for Java vs Go: Impressive solutions for invented problems
Jawher Moussa
Jawher Moussa

Posted on • Originally published at jawher.me

Java vs Go: Impressive solutions for invented problems

Originally published on my personal blog

Let's start with the Shadoks

3 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 cases
  • test_login is a parametrized test method that gets fed the different combinations

Shadok

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);
    }
}

Shadok

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);
    }

Shadok

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.

Top comments (28)

Collapse
 
pclundaahl profile image
Patrick Charles-Lundaahl

This is such an interesting observation!

I learned Java in school and have used it tangentially at work for the last ~2 years, but I've always bounced off how unnecessarily complex the ecosystem has always seemed. I've used Go for substantially less time, but I definitely find it far more intuitive and simple to use.

Thank you for the insight!

Collapse
 
leob profile image
leob • Edited

Java the language is okay, but everything "surrounding" it (tools, frameworks, ecosystem) is horribly overengineered - just look at tools like Eclipse or Maven, or frameworks like J2EE and Spring with their infinite "flexibility" and configurability (99% of which you rarely need, if ever).

I think the Java world never learned the mantra "less is more".

Collapse
 
jawher profile image
Jawher Moussa

Exactly ! It's not the language, it's the mindset around it.

Collapse
 
siy profile image
Sergiy Yevtushenko

Well, I think maven should not be in this list.

Thread Thread
 
leob profile image
leob • Edited

Hmm I beg to differ, I think Maven is also more complicated than it should be (compared to package managers in other languages like npm, gem, composer and so on).

But maybe that's not entirely Maven's fault, Java's build process is a lot more complex than for the other languages (which largely don't even have a build process), and Maven is of course not just a dependency manager but also a build tool.

Thread Thread
 
siy profile image
Sergiy Yevtushenko

Well, Maven is definitely not a simple tool to grasp and use efficiently, but it extremely powerful and versatile while still rather compact and fast. What is even more important, it's one of the most reliable build/dependency management tool in the long run. It's hard to underestimate this property for projects living for many years. Fancy attempts to replace Maven (I'm looking at you, Groovy) do not hold this property, unfortunately, although they somewhat address Maven steep learning curve.

Collapse
 
jawher profile image
Jawher Moussa

Thank you <3

It was very pleasant to hack on side projects in Go after a full day working with Java: its simplicity comes as a breath of fresh air.

Collapse
 
siy profile image
Sergiy Yevtushenko

Instead of switching language I've decided to try to change approach. In spare time I'm working on the project which takes significantly different approach to writing code in Java.

Collapse
 
leob profile image
leob

Haha brilliant, I completely agree that in the Java world "we" tend to horribly overcomplicate things, and the "declarative" approach is one of the biggest culprits.

To give an example of this other than JUnit:

I once worked on a project where the backend part was a REST API made with Spring. I didn't develop that part myself, but I had to read the code and make a few small changes to it.

What I noticed is that more than half of the code of that REST API was not Java code but Spring annotations (so the '@' stuff). What I observed was that the Java code was extremely trivial, but the annotations horribly complicated and opaque.

The biggest problem is when something goes wrong (doesn't work as expected), because how on earth are you then going to debug this stuff?

Suffice to say that I hated this style of coding. There is of course no reason why it has to be like this, Java does not mandate this style of coding, but it's become a bit of a "tradition" in the Java world (probably influenced by both JavaEE and Spring).

Collapse
 
jawher profile image
Jawher Moussa

What I noticed is that more than half of the code of that REST API was not Java code but Spring annotations (so the '@' stuff). What I observed was that the Java code was extremely trivial, but the annotations horribly complicated and opaque.

Which reminds me of annotatiomania.com/ :D

The biggest problem is when something goes wrong (doesn't work as expected), because how on earth are you then going to debug this stuff?

Exactly: you end up starting at a multi-hundred line stacktrace with layer after layer of Tomcat stuff, Spring security stuff, Spring MVC stuff, various proxies around your own code, ....

Collapse
 
190245 profile image
Dave

There is of course no reason why it has to be like this, Java does not mandate this style of coding, but it's become a bit of a "tradition" in the Java world (probably influenced by both JavaEE and Spring).

This is a very salient point.

I think the problem is the opinionated frameworks in the Java ecosystem. Spring, for example, does away with a lot of "boilerplate" code... but in doing so, you have to do things the "Spring way" - unless you spend the inordinate amount of time learning what's happening under the hood, and then optimising your code.

In a business environment, no Project Manager on the planet will care about Spring optimisations, because there is no direct business benefit (vs simply choosing a less opinionated approach).

Most experienced Java developers (that I've spoken to) passionately hate JavaEE. Some senior developers are slowly realising that Spring is a bit of a bell curve. In the early days, you don't understand Spring enough to see the benefits... in the middle, you love it... and towards the end, you realise just how much it gets in your way.

As with everything else in our world, it's the appropriate tool for the job, at the appropriate time. Unfortunately, deciding what's appropriate up front is a lot more difficult than with hindsight.

Collapse
 
jawher profile image
Jawher Moussa

In the early days, you don't understand Spring enough to see the benefits... in the middle, you love it... and towards the end, you realise just how much it gets in your way.

I couldn't have put it better 👍
This is exactly it.

Once you've learned the ropes with Spring:

  • You start a new project
  • You're glad to have Spring Boot to quickly bootstrap your app: the embedded tomcat server, the magic auto-discovery of your various config classes and beans, the transparent transaction handling, security, ...
  • Then you start a slow and never-ending cycle of fighting with the framework
  • You get a bug report: if I call the endpoint GET /tax/{code} with something with a dot in it, e.g. vat.20, I get a 500 error No serializer configured for the "20" media type
  • Furiously Google for the magic bean to override to tell Spring not to infer the response type from the extension in the path
  • Don't even get me started on the Spring Security mess: a supposedly extensible setup with dozens and dozens of beans, but good luck understanding the intricate inter-bean relations, which ones to override, how to )safely) override them, ... (I have so many horror stories wrt. the JWT part for example)
Thread Thread
 
190245 profile image
Dave

Spring Security

I'm yet to find a valid reason to include Spring Security in any corporate project. It's simply not maintainable enough for our use-case(s).

Thread Thread
 
jawher profile image
Jawher Moussa

the fear factor: "Are you really sure you want to reimplement your own security stack instead of going of the industry standardm battle-proven 15 year old Acegi/Spring Security stack ?" ¯_(ツ)_/¯

Thread Thread
 
190245 profile image
Dave

Re-inventing the wheel is not a good thing. There's options that aren't Spring Security. For example, for those that for some reason like XML, there's Shiro.

Our model though is that Front End applications implement security properly, and the middleware/backend treats the front end as a trusted component with very basic security requirements. This way, the Front End gets penetration tested and we don't have duplicated code/effort in the security layer. We don't have any Front End applications in Spring, and very few in Java.

Collapse
 
siy profile image
Sergiy Yevtushenko • Edited

I've used Spring since circa 2008 and better I knew it then more I disliked it. It poorly designed and even worse implemented. I really impressed how Spring guys were able to screw up every single idea they've implemented. They implemented slowest possible DI container (about 10x times slower than reflection-based class istantiation!). They made using string constants as a code and exceptions in business logic a everyday norm - which is just insane. Moreover, classic "business exceptions" (let's keep aside for the moment that this concept should not even exist) assumes that "business exceptions" are checked ones and present in method prototypes. Spring uses "technical exceptions" derived from RuntimeException in the place of "business exceptions" and hides remaining signs of business error handling. In other words, they were able to screw up even the idea which is already screwed up. I'm really, really impressed.

Thread Thread
 
leob profile image
leob • Edited

Haha totally agree, Spring is vastly overrated ... the point is of course that back then J2EE was so horrible that Spring felt like an improvement :-) but that doesn't say much, it only proves what an incredibly low bar was set by J2EE.

Maybe it could even be argued that Spring was quite okay in the beginning, but over the years it became more and more bloated and overcomplicated.

And at some point J2EE was improved and simplified so much that it was probably better and simpler (more "lightweight" even) than Spring ... but by then Spring had become totally dominant as the new "incumbent" and J2EE didn't stand a chance anymore.

But even years ago there were already Java frameworks which were much, much better than both J2EE and Spring ... Play (playframework.com) is an example, it does away with all the Servlets and container baggage and is much simpler and easier than either Spring or J2EE.

Thread Thread
 
siy profile image
Sergiy Yevtushenko

Well, I understand how Spring achieved it's popularity. I just realize that there are rarely any justification to use it in new projects. For those who can't imagine their project without Spring "magic" there is a modern alternative (Micronaut) which solves significant amount of Spring issues and shifts huge part of run time reflection to compile time.

There are a lot of Java Web frameworks which are better than Spring - Spark, Jooby, Vert.x, etc. Vert.x, for example usually one of the top performers in Techempower benchmarks.

In my spare time I'm working on similar framework which is based on Promise-based asynchronous processing model and asynchronous I/O API present in recent Linux kernels - io_uring. I hope it will be extremely fast while easy to use.

Collapse
 
siy profile image
Sergiy Yevtushenko

Spring is a great example how Java should not be used.

Collapse
 
moaxcp profile image
John Mercier

Hello, great post! I was just wondering how can you share the parameters in go with multiple tests. In junit the parameters can be shared with any test. This way you can test multiple aspects with the same data. The idea is to separate the data from the test. The go example is not exactly the same.

The data is in the test and even Case is defined in the test. Im not saying junit is better in any way, i'm just wondering if there is a way to separate the data from the test so it can be shared.

Collapse
 
jawher profile image
Jawher Moussa

Hi John and thank you for the kind words.

In junit the parameters can be shared with any test. This way you can test multiple aspects with the same data.

Right !
To achieve the same using the host language's constructs, you could:

1. define a package-level variable with the various cases:

type loginCase struct {
    Username string
    Password string
    Expected bool
}

var (
    staticLoginCases = []loginCase{
        {"username", "p@$$w0rd", true},
        {"incorrect-username", "p@$$w0rd", false},
        {"username", "incorrect-p@$$w0rd", false},
    }
)

func TestLogin(t *testing.T) {
    for _, tt := range staticLoginCases {
        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))
        })
    }
}

func TestAnotherLoginFeature(t *testing.T) {
    for _, tt := range staticLoginCases {
        t.Run(fmt.Sprintf("another test(%s, %s) => %v", tt.Username, tt.Password, tt.Expected), func(t *testing.T) {
            require.Equal(t, tt.Expected, login(tt.Username, tt.Password))
        })
    }
}

2. generate the test cases from a function

type loginCase struct {
    Username string
    Password string
    Expected bool
}

func generateLoginCases() []loginCase {
    var res []loginCase

    res = append(res, loginCase{"username", "p@$$w0rd", true})

    for i := 0; i < 500; i++ {
        res = append(res, loginCase{fmt.Sprintf("user#%d", i), "p@$$w0rd", false})
    }

    return res
}

func TestLogin(t *testing.T) {
    for _, tt := range generateLoginCases() {
        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))
        })
    }
}

func TestAnotherLoginFeature(t *testing.T) {
    for _, tt := range generateLoginCases() {
        t.Run(fmt.Sprintf("another test(%s, %s) => %v", tt.Username, tt.Password, tt.Expected), func(t *testing.T) {
            require.Equal(t, tt.Expected, login(tt.Username, tt.Password))
        })
    }
}

3. load the test cases from a JSON/Yaml/CSV/.. file

Again, you have the full standard library to achieve whatever you need :)

Collapse
 
moaxcp profile image
John Mercier

Yes! I think the same style could also be done in junit but it probably wouldn't pass a review. I do think that junit provides some nice ways to remove verbose code and just declare the parameters you want in the test. In some cases it is probably just better not to use it though. It depends on how common the code is and how much time is saves.

In graphs I was able to take advantage of these features in junit here, for example, but it is not for everyone.

Collapse
 
190245 profile image
Dave

I presume that the basis for your debate is this:

  • 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

Fortunately, both are factually wrong (at least for my 20 year career in Java). There is certainly nothing wrong with if or for within a unit test - I do that all the time, e.g.

String someVal = testSubject.someFunc();
switch (someVal) {
  case VALUE_ONE: // fall-through
  case VALUE_TWO:
    fail();
  case VALUE_THREE: // fall-through
  default:
    assertTrue(testSubject.otherFunc(someVal));
}

Of course, the above code violates SRP, but other than that, for illustrative purposes, the point stands.

Re your point about annotations, I refer you to Annotation Hell. E.g., almaer.com/blog/hibernate3-example... (no affiliation, just the first result in Google).

I actively prefer not to use Parametized Tests, regardless of the language I'm writing in, but horses for courses there. If pushed to, I would prefer a for loop within a test method, using a field variable within the Test class. It's not a strong preference though, and I wouldn't argue the point in a PR etc. My approach in Java is pretty close the the Go code you posted, funnily enough...

I've never understood the point of a conditional test, I typically want all my tests to run, and the tests can setup pre-conditions as necessary (such as pretending that the environment variable has been run). Conditional tests (to me) imply that the build server might not run them, but then the condition will be true when running in the Production environment.

The fact that JUnit doesn't guarantee order of test execution is positively a good thing. I don't want my tests to bleed through to each other. Sure, my tests might take longer to run (e.g., Spring context bootstrap...) but that's simply an optimisation problem, and we're talking about CPU cycles on the build server (as a developer, I should only be running the tests that I care about during this iteration, the build server can run the rest).

Now, typically I would add another framework into the mix to help with test setup, Mockito, and that kind of leads us into Dependency Hell too, but I'm sure there's many other ways to achieve the same too.

Collapse
 
jawher profile image
Jawher Moussa

Heya Dave,

There is certainly nothing wrong with if or for within a unit test - I do that all the time, e.g.

I mustn't have expressed my meaning clearly, apologies !

I wasn't referring to the code inside the test methods,

Rather, I was referring to the usage of annotations, e.g. @Test, @ParameterizedTest, @Get, @Post, ... to declare tests, rest endpoints, change the behaviour of methods, etc.

compared to the Go libraries philosophy of doing it explicitly & imperatively, e.g.t.Run("dynamic test case", func(t) { /* test code here */ }, or router.GET("/path", func() {} to declare a HTTP handler etc.

W.r.t. the annotation hell, I have a nice one: annotatiomania.com/ :D

I actively prefer not to use Parametized Tests, regardless of the language I'm writing in, but horses for courses there. If pushed to, I would prefer a for loop within a test method, using a field variable within the Test class. It's not a strong preference though, and I wouldn't argue the point in a PR etc. My approach in Java is pretty close the the Go code you posted, funnily enough...

I agree there: I'd rather whip out a for loop rather than have to google how ti use JUnit parameterized test.

The problem is: if a single case inside the for loop fails, the whole test is marked as red, and you have to go through the logs to identify which particular case failed (assuming you had the necessary logging set up).
The tooling (the in-IDE test runner, Jenkins, Surefire test reports, ...) does not accommodate this style very well.

Collapse
 
190245 profile image
Dave • Edited

The choice to use annotations, at any level, isn't mandated by Java. That's mandated by choices made during design of the application (or maybe mandated by corporate code-style agreements).

As of Java 8 onwards, I've actually been writing far more "functional programming" style Java, so my code probably looks a lot more like Go - because I love lambda's. Some, typically more junior, developers do tend to struggle with lambdas though, in my experience.

The problem is: if a single case inside the for loop fails, the whole test is marked as red, and you have to go through the logs to identify which particular case failed (assuming you had the necessary logging set up).

I'd argue that's a good thing. Fail fast. I'd argue that tests shouldn't write any logs. The point of failure should make it explicitly clear at which point the test failed (at least down to the line). If this is within a loop/stream/lambda then yes, things can get complicated, but then you're just asking a developer to run the test case in debug, with the IDE set to halt execution on exception. Further, most JUnit assertions will allow you to put an explicit statement in the exception's message.

Logs are useful for support purposes, but there's nothing wrong with a developer hooking up to a remote JVM using JDPA to find out the internal state of an application.

The tooling (the in-IDE test runner, Jenkins, Surefire test reports, ...) does not accommodate this style very well.

There, we certainly agree. All of the tooling (except perhaps javac itself) is strongly opinionated about how it expects the application design to be laid out.

PS., no need to apologise for anything - we're both entitled to hold different opinions, that's the point of a debate! It might be that one (or both) reconsiders, but equally, we can still respect each other's opinions without any change.

Collapse
 
siy profile image
Sergiy Yevtushenko

Well, with Go you often have no choice. With Java often you have too much choices. Go-like ways are present as well.

Collapse
 
rusith profile image
Shanaka Rusith • Edited

Unit tests should be independent of each other. shouldn't they?

Collapse
 
jawher profile image
Jawher Moussa

99.99% of the time yes.

I was thinking more in the lines of an integration test, where you would like to test a more complex workflow, e.g.:

  1. Test the login endpoint, and in case of success, capture the token for example and store it for later
  2. Using the token captured in 1., test a GET /documents endpoint, and assert that the response is empty
  3. Using the token, test POST /documents endpoint and assert that the response is 201, and capture/store the created document id
  4. test GET /documents a second time and ensure that the created document appears there