Java vs Go:针对人为问题提出的令人印象深刻的解决方案
我们先从沙多克斯家族说起。
《Les Shadoks》是雅克·鲁塞尔创作的一部卡通片,于 1968 年至 1974 年间在法国首次播出。
《Les Shadoks》系列剧产生了持久的文化影响,并引入了一些优美的短语/表达方式,至今仍在日常讨论中使用:
坚持不懈,终会成功。因此,失败越多,成功的可能性就越大。
或者:
任何优势都有其劣势,反之亦然。
甚至:
如果没有解决方案,那是因为根本没有问题。
但最重要的是:
既然可以选择困难的方式,为什么要选择简单的方式呢?
为什么提到Shadoks?
我从事Java编程工作已有13年。
幸运的是,我也会使用其他语言,主要是Go和Javascript。
使用 Go 语言让我注意到 Java 生态系统的一个模式:
它是一个庞大而丰富的生态系统,拥有非常强大且技术上令人印象深刻的库和框架。
但问题是,许多这样的库和框架都是针对一个原本不应该存在的问题而提出的令人印象深刻的解决方案:试图在所有地方都使用声明式方法。
案例分析:JUnit 测试
接下来,我将使用一个用 Java 编写的(单元)测试,并展示JUnit,它可以说是 Java 领域最流行和使用最广泛的测试框架,本文撰写时使用的是其最新版本,即代号为 Jupiter 的版本 5。
接下来会用到一些代码片段,请耐心听我解释,直到最后我把意思表达清楚。
在Java中,一切皆类
同样的道理也适用于考试:
import org.junit.jupiter.api.Test;
public class ExampleTest {
@Test
void test_login() {
assertThat(login("username", "p@$$w0rd")).isTrue();
}
}
如上面的代码片段所示,测试是:
- 一年级
- 用一种方法
- 使用注解
@Test告诉 JUnit 这是我们想要执行的测试代码。
测试用例
如果我们想login针对多个username和password组合测试我们的逻辑该怎么办?
一种方法是为每种组合创建一个新的测试方法:
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();
}
}
当出现以下情况时,这种情况很快就会变得难以控制:
- 测试方法正文篇幅过长,导致我们需要重复编写。
- 有很多测试用例,例如,几十种用户名/密码组合。
- 动态生成的测试用例,例如随机值、模糊测试等……
参数化测试
这就是为什么 JUnit 提供了一种方法,只需编写一次测试方法,然后根据我们提供的测试用例次数调用它:
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)
);
}
}
杂音很多,但基本上:
loginTestCases返回测试用例列表test_login是一种参数化测试方法,它会接收不同的组合作为输入。
测试用例命名
在测试报告中,默认情况下,参数化测试方法将获得一个动态生成的名称,该名称是它接收的所有参数的组合。
这可以通过使用模板字符串进行调整:
@ParameterizedTest(name = "login({0}, {1}) => {2}")
其中{0},,,{1}...将被相应位置的方法参数替换。
执行令
假设我们有两个必须按特定顺序运行的案例,例如:
- 第一个测试方法会登录用户并获取令牌。
- 第二种测试方法使用该令牌对系统进行进一步测试。
代码如下所示:
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);
}
}
但这样做并不会像预期那样奏效:
- JUnit 不保证测试方法的执行顺序。
- JUnit 不保证两个测试方法会重用同一个测试实例(因为需要共享字段
token)。
但别担心:还有更多注释可以帮忙:
@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);
}
}
条件测试
如果我们只想在满足特定条件时运行特定的测试方法该怎么办?
例如,假设我们有一个耗时的测试,我们希望它只在性能强大的 CI 代理上运行,例如根据E2E环境变量来决定。
@Test
@EnabledIfEnvironmentVariable(named = "E2E", matches = "true")
void testSlowApiCall() {
int res = apiCall(this.token);
assertThat(res).isEqualTo(42);
}
用 Go 语言如何实现同样的功能?
测试用例
Go 有:
- 切片/数组
- for循环
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))
})
}
}
我使用testify工具包来简化断言。
秩序,维持状态
Go 语言从上到下执行指令。
除非退出当前作用域,否则变量的值会保持不变。
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)
})
}
条件执行
Go 有ifswitch 键:
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)
})
}
}
结束语
Java生态系统出了严重问题:
出于某种原因,我们作为一个社群,集体决定:
- 我们应该只在业务/功能代码中使用该语言的结构(
if,,, …)。for - 对于其他任何东西,例如测试、配置、框架等等,我们则不得不发明一种不完善的声明式语言,最好是基于注解的。
最令人印象深刻的是,我们克服了这些自我设限,取得了如此大的成就。
我并非针对 JUnit。
恰恰相反,他们所取得的成就令人印象深刻。
一切都非常可扩展和可配置,想必他们为此投入了大量的时间和精力。
t.Run()然而,Go 测试库通过选择命令式模型(而非)实现了相同的功能/灵活性,同时更加简单,且表面积更小@Test,从而能够充分利用宿主语言的全部功能。



