章节

15.4 练习

本篇通过 13 道分级练习巩固测试函数、子测试与 TestMain,并能为函数、结构体、接口与文件场景编写可维护的 Go 单元测试。

练习

学完本章你应该掌握

  • 能创建 _test.go 测试文件,写出 TestXxx(t *testing.T) 形式的基础测试函数,并正确使用 go testgo test -v 运行测试。
  • 能根据场景选择 t.Fatalft.Errorf,知道什么时候应该立即终止当前测试,什么时候更适合把多个失败一次性报出来。
  • 能使用表格驱动测试和 t.Run 编写子测试,为同一个函数覆盖普通场景、边界值和错误路径,并能写出清晰的子测试名称。
  • 能识别测试里常见的组织问题,例如测试文件命名错误、测试函数签名错误、子测试场景名缺失、失败信息不明确。
  • 能使用 TestMain 在整个包的测试开始前准备统一环境,并在全部测试结束后完成清理。
  • 能把前面学过的字符串、切片、map、结构体、接口、错误处理和文件操作自然放进被测代码中,而不是只会为最简单的加法函数写测试。

简单

第 1 题:为 Add 写第一个测试

第 1 题 简单

已知业务代码如下:

1
2
3
4
5
package lesson

func Add(a, b int) int {
	return a + b
}

请新建 add_test.go,完成下面要求:

  • 编写测试函数 TestAdd(t *testing.T)
  • 调用 Add(1, 2)
  • 断言结果应该等于 3
  • 如果断言失败,使用 t.Fatalf 输出 gotwant
查看参考答案

文件名:add_test.go

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
package lesson

import "testing"

func TestAdd(t *testing.T) {
	got := Add(1, 2)
	want := 3

	if got != want {
		t.Fatalf("Add(1, 2) = %d, want %d", got, want)
	}
}

说明:这是最小可运行的 Go 单元测试写法,重点是 _test.go 文件名、TestXxx 函数名和 t *testing.T 参数都要写对。

第 2 题:修正不会被 go test 识别的测试

第 2 题 简单

已知业务代码如下:

1
2
3
4
5
package lesson

func Sub(a, b int) int {
	return a - b
}

下面这段测试代码保存在 sub.go 中,而且现在不会被 go test 正常识别。请改正它:

1
2
3
4
5
6
7
8
9
package lesson

import "testing"

func testSub(t testing.T) {
	if Sub(5, 2) != 3 {
		t.Fatal("bad result")
	}
}

要求:

  • 改成正确的测试文件名
  • 改成正确的测试函数名
  • 改成正确的函数参数类型
查看参考答案

正确的文件名应为:sub_test.go

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
package lesson

import "testing"

func TestSub(t *testing.T) {
	got := Sub(5, 2)
	want := 3

	if got != want {
		t.Fatalf("Sub(5, 2) = %d, want %d", got, want)
	}
}

这道题有 3 个关键修正:

  • 测试文件要以 _test.go 结尾,所以文件名不能叫 sub.go
  • 测试函数必须以 Test 开头,所以要把 testSub 改成 TestSub
  • 参数类型必须是 *testing.T,不能写成 testing.T

第 3 题:为切片求和函数写两个基础测试

第 3 题 简单

已知业务代码如下:

1
2
3
4
5
6
7
8
9
package lesson

func Sum(nums []int) int {
	total := 0
	for _, num := range nums {
		total += num
	}
	return total
}

请新建 sum_test.go,完成下面要求:

  • 编写 TestSum 输入 []int{3, 5, 7} 时,结果应该是 15
  • 编写 TestSumEmpty 输入空切片时,结果应该是 0
  • 两个测试都使用 t.Fatalf
查看参考答案

文件名:sum_test.go

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
package lesson

import "testing"

func TestSum(t *testing.T) {
	got := Sum([]int{3, 5, 7})
	want := 15

	if got != want {
		t.Fatalf("Sum([3 5 7]) = %d, want %d", got, want)
	}
}

func TestSumEmpty(t *testing.T) {
	got := Sum([]int{})
	want := 0

	if got != want {
		t.Fatalf("Sum([]) = %d, want %d", got, want)
	}
}

说明:这道题虽然只用了切片和循环的基础知识,但真正要练的是“一个场景一个测试函数”的最基本测试组织方式。

第 4 题:使用 t.Errorf 连续检查偶数判断

第 4 题 简单

已知业务代码如下:

1
2
3
4
5
package lesson

func IsEven(n int) bool {
	return n%2 == 0
}

请编写 TestIsEven,在一个测试函数里连续检查下面 3 组数据:

  • 2 -> true
  • 11 -> false
  • 0 -> true

要求:

  • 如果某一组失败,不要立刻结束整个测试函数
  • 使用 t.Errorf 报告失败
查看参考答案

文件名:is_even_test.go

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
package lesson

import "testing"

func TestIsEven(t *testing.T) {
	cases := []struct {
		n    int
		want bool
	}{
		{n: 2, want: true},
		{n: 11, want: false},
		{n: 0, want: true},
	}

	for _, item := range cases {
		got := IsEven(item.n)
		if got != item.want {
			t.Errorf("IsEven(%d) = %t, want %t", item.n, got, item.want)
		}
	}
}

说明:这里使用 t.Errorf 的好处是,就算第一组失败,后面的用例仍然会继续检查,方便一次看到所有不符合预期的场景。

一般

下面 5 题会开始大量使用表格驱动测试和子测试,让一组输入输出场景更清晰,也更接近真实项目中的测试写法。

第 5 题:用表格驱动子测试验证字符个数

第 5 题 一般

已知业务代码如下:

1
2
3
4
5
package lesson

func CountChars(text string) int {
	return len([]rune(text))
}

请编写 TestCountChars,要求使用“表格驱动测试 + t.Run 子测试”覆盖下面场景:

  • "Go" 的字符数是 2
  • "Go语言" 的字符数是 4
  • "" 的字符数是 0
查看参考答案

文件名:count_chars_test.go

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
package lesson

import "testing"

func TestCountChars(t *testing.T) {
	cases := []struct {
		name string
		text string
		want int
	}{
		{name: "ascii", text: "Go", want: 2},
		{name: "mixed", text: "Go语言", want: 4},
		{name: "empty", text: "", want: 0},
	}

	for _, item := range cases {
		item := item
		t.Run(item.name, func(t *testing.T) {
			got := CountChars(item.text)
			if got != item.want {
				t.Fatalf("CountChars(%q) = %d, want %d", item.text, got, item.want)
			}
		})
	}
}

说明:这道题顺手复习了前面字符串章节里的 []rune,而测试章节真正想练的是“把多组场景收进一张用例表,再用子测试逐个执行”。

第 6 题:用子测试覆盖成绩等级边界

第 6 题 一般

已知业务代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
package lesson

func Grade(score int) string {
	switch {
	case score < 0 || score > 100:
		return "非法"
	case score >= 90:
		return "A"
	case score >= 80:
		return "B"
	case score >= 60:
		return "C"
	default:
		return "D"
	}
}

请编写 TestGrade,要求使用表格驱动子测试覆盖至少下面这些边界值:

  • -1 -> 非法
  • 0 -> D
  • 59 -> D
  • 60 -> C
  • 79 -> C
  • 80 -> B
  • 90 -> A
  • 101 -> 非法
查看参考答案

文件名:grade_test.go

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
package lesson

import "testing"

func TestGrade(t *testing.T) {
	cases := []struct {
		name  string
		score int
		want  string
	}{
		{name: "negative", score: -1, want: "非法"},
		{name: "zero", score: 0, want: "D"},
		{name: "d_upper", score: 59, want: "D"},
		{name: "c_lower", score: 60, want: "C"},
		{name: "c_upper", score: 79, want: "C"},
		{name: "b_lower", score: 80, want: "B"},
		{name: "a_lower", score: 90, want: "A"},
		{name: "too_large", score: 101, want: "非法"},
	}

	for _, item := range cases {
		item := item
		t.Run(item.name, func(t *testing.T) {
			got := Grade(item.score)
			if got != item.want {
				t.Fatalf("Grade(%d) = %s, want %s", item.score, got, item.want)
			}
		})
	}
}

说明:等级函数最容易出错的地方通常不是“普通输入”,而是 59/6079/8090 这些边界,所以这里专门把边界值单独列成了测试用例。

第 7 题:修正一个写得不清晰的子测试

第 7 题 一般

已知业务代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
package lesson

type TaskStatus int

const (
	StatusTodo TaskStatus = 1
	StatusDone TaskStatus = 2
)

func (s TaskStatus) Text() string {
	switch s {
	case StatusTodo:
		return "待开始"
	case StatusDone:
		return "已完成"
	default:
		return "未知状态"
	}
}

下面这段测试也能表达“多场景校验”的意思,但写法不够符合本章建议。请把它改成更清晰的版本:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
func TestTaskStatusText(t *testing.T) {
	cases := []struct {
		name   string
		status TaskStatus
		want   string
	}{
		{name: "todo", status: StatusTodo, want: "待开始"},
		{name: "done", status: StatusDone, want: "已完成"},
	}

	for _, item := range cases {
		t.Run("", func(t *testing.T) {
			if item.status.Text() != item.want {
				t.Fatalf("bad")
			}
		})
	}
}

要求:

  • 子测试名称使用 item.name
  • 按本章推荐写法补上 item := item
  • 失败信息输出 gotwant
查看参考答案

修正后的测试代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package lesson

import "testing"

func TestTaskStatusText(t *testing.T) {
	cases := []struct {
		name   string
		status TaskStatus
		want   string
	}{
		{name: "todo", status: StatusTodo, want: "待开始"},
		{name: "done", status: StatusDone, want: "已完成"},
	}

	for _, item := range cases {
		item := item
		t.Run(item.name, func(t *testing.T) {
			got := item.status.Text()
			if got != item.want {
				t.Fatalf("TaskStatus(%d).Text() = %s, want %s", item.status, got, item.want)
			}
		})
	}
}

说明:子测试名字一旦写清楚,go test -v 输出里就能直接定位是哪个场景失败;而失败信息里补上 got / want,排查起来会快很多。

第 8 题:为 map 查询函数补成功和失败子测试

第 8 题 一般

已知业务代码如下:

1
2
3
4
5
6
package lesson

func FindAge(ages map[string]int, name string) (int, bool) {
	age, ok := ages[name]
	return age, ok
}

请编写 TestFindAge,要求使用子测试覆盖下面两个场景:

  • 在 map 中能找到 "阿斌",结果是 21, true
  • 在 map 中找不到 "小李",结果是 0, false

提示:测试数据可以使用下面这张表:

1
2
3
4
ages := map[string]int{
	"阿斌": 21,
	"小王": 18,
}
查看参考答案

文件名:find_age_test.go

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
package lesson

import "testing"

func TestFindAge(t *testing.T) {
	ages := map[string]int{
		"阿斌": 21,
		"小王": 18,
	}

	cases := []struct {
		name    string
		query   string
		wantAge int
		wantOK  bool
	}{
		{name: "found", query: "阿斌", wantAge: 21, wantOK: true},
		{name: "not_found", query: "小李", wantAge: 0, wantOK: false},
	}

	for _, item := range cases {
		item := item
		t.Run(item.name, func(t *testing.T) {
			gotAge, gotOK := FindAge(ages, item.query)
			if gotAge != item.wantAge || gotOK != item.wantOK {
				t.Fatalf(
					"FindAge(%q) = (%d, %t), want (%d, %t)",
					item.query,
					gotAge,
					gotOK,
					item.wantAge,
					item.wantOK,
				)
			}
		})
	}
}

说明:这道题顺手复习了 map 的“存在 / 不存在”两条路径,而测试章节真正想强调的是:不要只测成功场景,失败路径同样应该被覆盖。

第 9 题:为结构体指针方法写测试

第 9 题 一般

已知业务代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
package lesson

type Student struct {
	Name  string
	Score int
}

func (s *Student) AddBonus(points int) {
	s.Score += points
}

请编写 TestStudentAddBonus,完成下面要求:

  • 测试 Score=58 时调用 AddBonus(5),最后应该变成 63
  • 再补一个 AddBonus(0) 的场景,确认分数不会被意外改错
  • 建议使用子测试
查看参考答案

文件名:student_test.go

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
package lesson

import "testing"

func TestStudentAddBonus(t *testing.T) {
	cases := []struct {
		name   string
		start  int
		points int
		want   int
	}{
		{name: "add_five", start: 58, points: 5, want: 63},
		{name: "add_zero", start: 76, points: 0, want: 76},
	}

	for _, item := range cases {
		item := item
		t.Run(item.name, func(t *testing.T) {
			student := Student{
				Name:  "小李",
				Score: item.start,
			}

			student.AddBonus(item.points)

			if student.Score != item.want {
				t.Fatalf(
					"student.Score after AddBonus(%d) = %d, want %d",
					item.points,
					student.Score,
					item.want,
				)
			}
		})
	}
}

说明:这道题表面上在测结构体和指针方法,实际是在练“每个子测试都自己准备数据,避免不同场景互相污染”。

进阶

下面 4 题会把错误路径、接口、文件操作和完整测试组织方式一起串起来,更接近真实项目里的 Go 测试代码。

第 10 题:为返回 error 的年龄解析函数补成功和失败场景

第 10 题 进阶

已知业务代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
package lesson

import (
	"fmt"
	"strconv"
)

func ParseAge(text string) (int, error) {
	age, err := strconv.Atoi(text)
	if err != nil {
		return 0, fmt.Errorf("parse age: %w", err)
	}
	return age, nil
}

请编写 TestParseAge,要求使用子测试覆盖下面两个场景:

  • "18" 应该返回 18nil
  • "abc" 应该返回非空错误,并且错误信息中至少包含 parse age

提示:可以使用 strings.Contains

查看参考答案

文件名:parse_age_test.go

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
package lesson

import (
	"strings"
	"testing"
)

func TestParseAge(t *testing.T) {
	t.Run("success", func(t *testing.T) {
		got, err := ParseAge("18")
		if err != nil {
			t.Fatalf("ParseAge(\"18\") returned unexpected error: %v", err)
		}
		if got != 18 {
			t.Fatalf("ParseAge(\"18\") = %d, want 18", got)
		}
	})

	t.Run("invalid_text", func(t *testing.T) {
		got, err := ParseAge("abc")
		if err == nil {
			t.Fatalf("ParseAge(\"abc\") returned nil error, want non-nil error")
		}
		if got != 0 {
			t.Fatalf("ParseAge(\"abc\") = %d, want 0", got)
		}
		if !strings.Contains(err.Error(), "parse age") {
			t.Fatalf("error = %q, want it contains %q", err.Error(), "parse age")
		}
	})
}

说明:这道题重点不是 strconv.Atoi 本身,而是训练你在测试里同时覆盖“成功路径”和“错误路径”,这正是本章最容易漏掉的一类场景。

第 11 题:为接口统一计算函数写测试

第 11 题 进阶

已知业务代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
package lesson

type Payable interface {
	Pay() int
}

type CourseOrder struct {
	Title string
	Price int
}

func (c CourseOrder) Pay() int {
	return c.Price
}

type BookOrder struct {
	Title    string
	Price    int
	Discount int
}

func (b BookOrder) Pay() int {
	return b.Price - b.Discount
}

func CalcTotal(items []Payable) int {
	total := 0
	for _, item := range items {
		total += item.Pay()
	}
	return total
}

请编写 TestCalcTotal,要求至少覆盖下面两个场景:

  • 正常场景: CourseOrder{Title: "Go 接口课", Price: 128} BookOrder{Title: "Go 练习册", Price: 99, Discount: 20} 结果应该是 207
  • 空切片场景: 结果应该是 0

建议使用子测试。

查看参考答案

文件名:calc_total_test.go

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
package lesson

import "testing"

func TestCalcTotal(t *testing.T) {
	cases := []struct {
		name  string
		items []Payable
		want  int
	}{
		{
			name: "normal",
			items: []Payable{
				CourseOrder{Title: "Go 接口课", Price: 128},
				BookOrder{Title: "Go 练习册", Price: 99, Discount: 20},
			},
			want: 207,
		},
		{
			name:  "empty",
			items: []Payable{},
			want:  0,
		},
	}

	for _, item := range cases {
		item := item
		t.Run(item.name, func(t *testing.T) {
			got := CalcTotal(item.items)
			if got != item.want {
				t.Fatalf("CalcTotal(...) = %d, want %d", got, item.want)
			}
		})
	}
}

说明:这道题顺手复习了接口、多态和切片,但测试章节真正想练的是“同一个函数面对不同业务场景时,如何用子测试把用例组织清楚”。

第 12 题:使用 TestMain 为文件读取测试准备环境

第 12 题 进阶

已知业务代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
package lesson

import (
	"os"
	"strings"
)

func ReadFirstLine(path string) (string, error) {
	data, err := os.ReadFile(path)
	if err != nil {
		return "", err
	}

	lines := strings.Split(string(data), "\n")
	return lines[0], nil
}

请编写 reader_test.go,完成下面要求:

  • 写一个包级 TestMain(m *testing.M)
  • 在所有测试执行前创建测试文件 lesson.txt 文件内容为:
1
2
Go 单元测试
第二行
  • 在所有测试执行后删除这个文件
  • 编写 TestReadFirstLine 读取 lesson.txt 时,结果应该是 Go 单元测试
  • 编写 TestReadFirstLineMissing 读取不存在的文件时,应该返回非空错误
查看参考答案

文件名:reader_test.go

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
package lesson

import (
	"os"
	"testing"
)

var testFilePath = "lesson.txt"

func TestMain(m *testing.M) {
	err := os.WriteFile(testFilePath, []byte("Go 单元测试\n第二行"), 0644)
	if err != nil {
		panic(err)
	}

	code := m.Run()

	_ = os.Remove(testFilePath)
	os.Exit(code)
}

func TestReadFirstLine(t *testing.T) {
	got, err := ReadFirstLine(testFilePath)
	if err != nil {
		t.Fatalf("ReadFirstLine(%q) returned unexpected error: %v", testFilePath, err)
	}

	want := "Go 单元测试"
	if got != want {
		t.Fatalf("ReadFirstLine(%q) = %q, want %q", testFilePath, got, want)
	}
}

func TestReadFirstLineMissing(t *testing.T) {
	_, err := ReadFirstLine("missing.txt")
	if err == nil {
		t.Fatalf("ReadFirstLine(\"missing.txt\") returned nil error, want non-nil error")
	}
}

说明:TestMain 适合放“整个包共享的准备和清理逻辑”。这道题把前一单元的文件操作自然接进来了,但真正要练的是 m.Run()、统一准备和统一清理这套测试组织方式。

第 13 题:综合为班级成绩报告写一组完整测试

第 13 题 进阶

已知业务代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
package lesson

import "errors"

type Student struct {
	Name  string
	Score int
}

func BuildReport(students []Student) (passCount int, avg float64, top Student, err error) {
	if len(students) == 0 {
		return 0, 0, Student{}, errors.New("没有学生数据")
	}

	total := 0
	top = students[0]

	for _, student := range students {
		total += student.Score
		if student.Score >= 60 {
			passCount++
		}
		if student.Score > top.Score {
			top = student
		}
	}

	avg = float64(total) / float64(len(students))
	return passCount, avg, top, nil
}

请编写 TestBuildReport,要求使用表格驱动子测试覆盖至少下面两个场景:

  • 正常场景:
1
2
3
4
5
6
[]Student{
	{Name: "小李", Score: 82},
	{Name: "小王", Score: 59},
	{Name: "小张", Score: 92},
	{Name: "小周", Score: 76},
}

期望结果: passCount = 3 avg = 77.25 top = Student{Name: "小张", Score: 92} err = nil

  • 空切片场景: 期望返回非空错误

提示:比较浮点数平均分时,可以使用 math.Abs

查看参考答案

文件名:report_test.go

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
package lesson

import (
	"math"
	"testing"
)

func TestBuildReport(t *testing.T) {
	cases := []struct {
		name          string
		students      []Student
		wantPassCount int
		wantAvg       float64
		wantTop       Student
		wantErr       bool
	}{
		{
			name: "normal",
			students: []Student{
				{Name: "小李", Score: 82},
				{Name: "小王", Score: 59},
				{Name: "小张", Score: 92},
				{Name: "小周", Score: 76},
			},
			wantPassCount: 3,
			wantAvg:       77.25,
			wantTop:       Student{Name: "小张", Score: 92},
			wantErr:       false,
		},
		{
			name:          "empty",
			students:      []Student{},
			wantPassCount: 0,
			wantAvg:       0,
			wantTop:       Student{},
			wantErr:       true,
		},
	}

	for _, item := range cases {
		item := item
		t.Run(item.name, func(t *testing.T) {
			gotPassCount, gotAvg, gotTop, err := BuildReport(item.students)

			if item.wantErr {
				if err == nil {
					t.Fatalf("BuildReport(...) returned nil error, want non-nil error")
				}
				return
			}

			if err != nil {
				t.Fatalf("BuildReport(...) returned unexpected error: %v", err)
			}

			if gotPassCount != item.wantPassCount {
				t.Fatalf("passCount = %d, want %d", gotPassCount, item.wantPassCount)
			}

			if math.Abs(gotAvg-item.wantAvg) > 0.0001 {
				t.Fatalf("avg = %.2f, want %.2f", gotAvg, item.wantAvg)
			}

			if gotTop != item.wantTop {
				t.Fatalf("top = %+v, want %+v", gotTop, item.wantTop)
			}
		})
	}
}

说明:这道题把结构体、切片、循环、条件判断、错误返回和浮点数比较全都放进了被测代码里,而测试章节真正想练的是“如何把正常路径和错误路径一起组织进一套清晰的测试用例”。

本文禁止转载
使用 Hugo 构建
主题 StackJimmy 设计 由 Hobin 魔改
最近构建时间:2026-04-17 19:07:48 CST
载入天数...载入时分秒...
发表了 1 篇文章 · 发表了 152 篇笔记 · 总计 18 万 0 千字