探索golang测试的多种形式

在软件开发中,测试是确保代码质量和功能稳定性的关键步骤之一。然而,除了其在质量保证方面的重要性之外,测试还对我们的编程思想产生了深远的影响。通过本文,我们将探讨测试如何塑造我们的编程思维,以及如何通过测试驱动开发(TDD)、重构和面向对象编程等技术来提高代码质量和开发效率。

测试的种类

单元测试

单元测试是一个代码块,用于验证较小、孤立的应用程序代码块(通常是函数或方法)的准确性

单元测试的要求:

  • 程序需要采用这种命名规则 xxx_test.go
  • 测试函数名必须以Test 开头
  • 测试函数参数参数是 t *testing.T

性能测试

性能测试是采用测试手段对软件的响应及时性进行评价的一种方式

性能测试的要求:

  • 程序需要采用这种命名规则 xxx_test.go
  • 测试函数名必须以Benchmark 开头
  • 测试函数参数参数是 b *testing.B

模糊测试

模糊测试是通过向目标系统提供非预期的输入并监视异常结果来发现软件漏洞的方法

模糊测试的要求:

  • 程序需要采用这种命名规则 xxx_test.go
  • 测试函数名必须以Fuzz 开头
  • 测试函数参数参数是 f *testing.F

测试驱动开发

如果采用测试驱动开发(TDD)的流程,我们会先编写测试用例,再基于测试用例来实现最小可行的代码。这种做法可以促使我们先思考设计,再动手编码,从而产生更加优雅、简洁的代码。

现在我们现在有一个需求,是求解矩形的面积。按照我们常规的思路我们是先实现面积的代码,然后再编写测试用例。但是在很多情况下,实现完需求就接着写其他的了。并不会编写测试用例,这样代码并不能保证它的可靠性。

所以我们应该采用TDD的形式来开发,会得到不一样的效果。

rectangle_test.go

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

import "testing"

func TestRectangleArea(t *testing.T) {
	got := Area(2, 5)
	want := 10.0
	if got != want {
		t.Errorf("got '%f' want '%f'", got, want)
	}
}
1
2
3
4
❯ go test -v
# demo/rectangle [demo/rectangle.test]
./rectangle_test.go:6:9: undefined: Area
FAIL    demo/rectangle [build failed]

然后我们再具体实现方法。

rectangle.go

1
2
3
4
5
package rectangle

func Area(width, height float64) float64 {
	return width * height
}
1
2
3
4
5
 go test -v
=== RUN   TestRectangleArea
--- PASS: TestRectangleArea (0.00s)
PASS
ok      demo/rectangle  0.006s

重构

上面是一个很简单的用例,我们需要考虑更多可能,所以我们可以引入表格测试的概念

rectangle_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
package rectangle

import "testing"

func TestArea(t *testing.T) {
	type args struct {
		width  float64
		height float64
	}
	tests := []struct {
		name string
		args args
		want float64
	}{
		{
			name: "the same",
			args: args{
				width:  1,
				height: 1,
			},
			want: 1.0,
		},
		{
			name: "have zero",
			args: args{
				width:  2,
				height: 0,
			},
			want: 0.0,
		},
		{
			name: "set float",
			args: args{
				width:  2.1,
				height: 5.3,
			},
			want: 11.13,
		},
		{
			name: "both zero",
			args: args{
				height: 0,
				width:  0,
			},
			want: 0.0,
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			if got := Area(tt.args.width, tt.args.height); got != tt.want {
				t.Errorf("Area() = %v, want %v", got, tt.want)
			}
		})
	}
}

我们接到一个需求是计算矩形的周长,此时我们优先将测试代码实现了。

rectangle_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
func TestCircumference(t *testing.T) {
	type args struct {
		width  float64
		height float64
	}
	tests := []struct {
		name string
		args args
		want float64
	}{
		{
			name: "the same",
			args: args{
				width:  1,
				height: 1,
			},
			want: 4.0,
		},
		{
			name: "have zero",
			args: args{
				width:  2,
				height: 0,
			},
			want: 0.0,
		},
		{
			name: "set float",
			args: args{
				width:  2.1,
				height: 5.3,
			},
			want: 14.8,
		},
		{
			name: "both zero",
			args: args{
				height: 0,
				width:  0,
			},
			want: 0.0,
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			if got := Circumference(tt.args.width, tt.args.height); got != tt.want {
				t.Errorf("Area() = %v, want %v", got, tt.want)
			}
		})
	}
}

然后我们要实现函数时,你有没有发现和我们求面积的时候入参和出参都一样,而且它都属于矩形的计算的一种。如果我们还是按照原来的函数来进行开发,用户就会很难受,我面积周长都要传参。那我们改造一下吧!

rectangle.go

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

type Rectangle struct {
    Width, Height float64
}

func (r Rectangle) Area() float64 {
	return r.Width * r.Height
}

func (r Rectangle) Circumference() float64 {
	return 2 * (r.Width + r.Height)
}

这样对于调用方来说只需要new一次Rectangle,就可以计算AreaCircumference

此时我们的测试代码又报红了,我们来修改它,让它不再报错。

rectangle_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
package rectangle

import "testing"

func TestRectangle_Area(t *testing.T) {
	type fields struct {
		Width  float64
		Height float64
	}
	tests := []struct {
		name   string
		fields fields
		want   float64
	}{
		// code .......
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			r := Rectangle{
				Width:  tt.fields.Width,
				Height: tt.fields.Height,
			}
			if got := r.Area(); got != tt.want {
				t.Errorf("Rectangle.Area() = %v, want %v", got, tt.want)
			}
		})
	}
}

func TestRectangle_Circumference(t *testing.T) {
	type fields struct {
		Width  float64
		Height float64
	}
	tests := []struct {
		name   string
		fields fields
		want   float64
	}{
		// code .......
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			r := Rectangle{
				Width:  tt.fields.Width,
				Height: tt.fields.Height,
			}
			if got := r.Circumference(); got != tt.want {
				t.Errorf("Rectangle.Circumference() = %v, want %v", got, tt.want)
			}
		})
	}
}

面向对象

此时我们又接到一个需求需要计算圆的面积和周长。我们会发现这不是和我们写矩形的时候一样吗?没错,在大多数图形来讲都有计算面积的公式,所以我们可以对它进行抽象。 shape.go

1
2
3
4
5
6
package geometry

type Shape interface {
    Area() float64
    Circumference() float64
}

然后再将圆实现这两个接口。这样的好处就在于如果我们对图形还要包一层进行业务开发,我们就无须指定图形的类型就能执行下面的方法了。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// 业务需求:计算一个画板上的所有的图形面积(不一定很贴切)
type DrawingBoard struct {
	shapes []Shape
}

func (db DrawingBoard) Area() float64 {
	sum := 0.0
	for _, v := range db.shapes {
		sum += v.Area()
	}
	return sum
}

像这种接口我们应该如何测试呢?因为它并没有具体的对象,所以这种情况我们就用使用mock来实现了。

1
2
3
4
5
//go:generate mockgen -destination=./mock/mock_shape.go -package mock -source=shape.go
type Shape interface {
    Area() float64
    Circumference() float64
}
1
go generate

此时就会给我们生成mock代码。那我们业务代码要如何进行测试呢?

 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
70
71
72
73
74
75
76
77
78
79
package geometry

import (
	"xxx/xxx/geometry/mock"
	"testing"

	"go.uber.org/mock/gomock"
)

func TestDrawingBoard_Area(t *testing.T) {
	ctr := gomock.NewController(t)
	defer ctr.Finish()

	mockShape := mock.NewMockShape(ctr)
	mockShape.EXPECT().Area().DoAndReturn(func() float64 {
		return 10.0
	})

	mockShape1 := mock.NewMockShape(ctr)
	mockShape1.EXPECT().Area().DoAndReturn(func() float64 {
		return 0.0
	})

	mockShape2 := mock.NewMockShape(ctr)
	mockShape2.EXPECT().Area().DoAndReturn(func() float64 {
		return 0.0
	})

	mockShape3 := mock.NewMockShape(ctr)
	mockShape3.EXPECT().Area().DoAndReturn(func() float64 {
		return 5.5
	})

	type fields struct {
		shapes []Shape
	}
	tests := []struct {
		name   string
		fields fields
		want   float64
	}{
		{
			name: "mock one",
			fields: fields{
				shapes: []Shape{mockShape},
			},
			want: 10.0,
		},
		{
			name: "zero",
			fields: fields{
				shapes: []Shape{
					mockShape1,
				},
			},
			want: 0.0,
		},
		{
			name: "have zero",
			fields: fields{
				shapes: []Shape{
					mockShape2,
					mockShape3,
				},
			},
			want: 5.5,
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			db := DrawingBoard{
				shapes: tt.fields.shapes,
			}
			if got := db.Area(); got != tt.want {
				t.Errorf("DrawingBoard.Area() = %v, want %v", got, tt.want)
			}
		})
	}
}

我们通过mock来帮助我们实现了这些实例。这个例子比较简单,所以有时候也可以不需要这么写,你也可以自己定义对象来实现接口。

代码安全

模糊测试通常是自动化的,可用于测试功能和安全缺陷。 功能性模糊测试包括向程序输入无效数据以检查意外行为。

在有些方法中,我们不可能将所有可能性都枚举出来,此时fuzz就能帮助我们了。

我们有一个需求,是比较两个字节数组是否相等。单元测试代码我就省略了,可以结合上面的代码自己实现哦。

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

func Equal(a []byte, b []byte) bool {
	// 检查切片的长度是否相同
	if len(a) != len(b) {
		return false
	}
	for i := range a {
		// 检查同一索引中的元素是否相同
		if a[i] != b[i] {
			return false
		}
	}
	return true
}
1
2
3
4
5
func FuzzEqual(f *testing.F) {
	f.Fuzz(func(t *testing.T, a, b []byte) {
		Equal(a, b)
	})
}
1
go test -fuzz .

需要注意的是fuzz需要手动进行停止。

总结

通过测试和测试驱动开发(TDD),我们能够改变我们的编程思维方式。测试不仅仅是一种验证代码正确性的手段,更重要的是它塑造了我们对代码的设计、编写和维护的思考方式。采用TDD的开发模式,我们首先编写测试用例,然后再编写代码以满足这些测试用例,这促使我们在编写代码之前就深入思考问题的解决方案,从而产生更加健壮、可靠、可维护的代码。

通过重构技术,我们能够改进现有的代码结构和设计,使其更加清晰、简洁、易于理解和维护。重构是持续改进代码质量的过程,通过识别和消除代码中的坏味道,我们能够不断提高代码的可读性、可维护性和灵活性。

面向对象编程提供了一种组织和抽象代码的方式,通过将数据和方法封装在对象中,我们能够更好地管理复杂性,并实现代码的重用性和可扩展性。面向对象的思想使我们能够将现实世界中的问题映射到代码中,从而更好地理解和解决问题。

最后,通过测试和模糊测试,我们能够确保代码的安全性和稳定性。测试不仅仅是一种验证功能的手段,还可以帮助我们发现和修复潜在的安全漏洞和异常行为,从而提高系统的可靠性和安全性。

综上所述,测试对编程思维的影响是深远的,它不仅改变了我们对代码质量和可靠性的认识,还促使我们采用更加健壮和可维护的开发方式,从而提高软件开发的效率和质量。