Java vs Go: Impressive solutions for invented problems

Originally published on my personal blog

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 cases
  • test_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.

原文链接:Java vs Go: Impressive solutions for invented problems

© 版权声明
THE END
喜欢就支持一下吧
点赞13 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容