Go-知识测试-单元测试

Go-知识测试-单元测试

  • 1. 定义
  • 2. 使用
  • 3. testing.common 测试基础数据
  • 4. testing.TB 接口
  • 5. 单元测试的原理
    • 5.1 context 单元测试的调度
      • 5.1.1 等待并发执行 testContext.waitParallel
      • 5.1.2 并发测试结束 testContext.release
    • 5.2 测试执行 tRunner
    • 5.3 启动测试 Run
    • 5.4 启动并发测试 Parallel

1. 定义

单元测试是指对软件中的最小可测试单元进行检查和验证,比如对一个函数的测试。
单元测试要保证测试文件以_test.go结尾。
测试方法必须以TestXxx开头。
测试文件可以与源码处于同一目录,也可以处于单独的目录。

2. 使用

比如
在这里插入图片描述

执行单个TestXxx
在这里插入图片描述

执行整个测试文件
在这里插入图片描述

使用 go test 即可触发测试

3. testing.common 测试基础数据

每个单元测试都有一个入参t *testing.T,结构定义如下:

type T struct {
	common
	isEnvSet bool
	context  *testContext // For running tests and subtests.
}

T 组合了 common 类型

// common包含T和B之间的公共元素,以及
// 捕获常见的方法,如Errorf。
type common struct {
	mu          sync.RWMutex         // 保卫这群田地
	output      []byte               // 测试或基准测试生成的输出。
	w           io.Writer            // 对于flushToParent。
	ran         bool                 // 执行了测试或基准测试(或其中一个子测试)。
	failed      bool                 // 测试或基准测试失败。
	skipped     bool                 // 已跳过测试或基准测试。
	done        bool                 // 测试已完成,所有子测试均已完成。
	helperPCs   map[uintptr]struct{} // 写入文件/行信息时要跳过的函数
	helperNames map[string]struct{}  // helperPC转换为函数名
	cleanups    []func()             // 测试结束时要调用的可选函数
	cleanupName string               // 清除函数的名称。
	cleanupPc   []uintptr            // 调用Cleanup的点处的堆栈跟踪。
	finished    bool                 // 测试功能已完成。
	chatty      *chattyPrinter       // 如果设置了chatty标志,则为chattyPrinter的副本。
	bench       bool                 // 当前测试是否为基准测试。
	hasSub      int32                // 以原子形式书写。
	raceErrors  int                  // 测试过程中检测到的种族数。
	runner      string               // 运行测试的tRunner的函数名称。
	parent      *common
	level       int       // 测试或基准的嵌套深度。
	creator     []uintptr // 如果级别>0,则堆栈跟踪父级调用t.Run的点。
	name        string    // 测试或基准的名称。
	start       time.Time // 时间测试或基准测试已启动
	duration    time.Duration
	barrier     chan bool // 为了发出平行子测验的信号,他们可以开始。
	signal      chan bool // 发出测试完成的信号。
	sub         []*T      // 要并行运行的子测试的队列。
	tempDirMu   sync.Mutex
	tempDir     string
	tempDirErr  error
	tempDirSeq  int32
}

每个测试均对应一个 testing.common,不仅记录了测试函数的基础信息(比如名字),还管理了测试的执行过程和测试结果。
testing.commong是单元测试,性能测试和模糊测试的基础。
通过继承共同的结构,保证了各种测试的行为一致,降低使用的门槛。

4. testing.TB 接口

testing.common 实现的接口为 testing.TB,单元测试和性能测试通过该接口获取基础能力。

type TB interface {
	Cleanup(func())                            // 清理
	Error(args ...interface{})                 // 表示测试失败+记录日志
	Errorf(format string, args ...interface{}) // 格式化表示测试失败+记录日志
	Fail()                                     // 表示测试失败
	FailNow()                                  // 标记测试失败+结束当前测试
	Failed() bool                              // 查询结果
	Fatal(args ...interface{})                 // 标记测试失败+记录日志+结束当前测试
	Fatalf(format string, args ...interface{}) // 格式化标记测试失败+记录日志+结束当前测试
	Helper()                                   // 标记测试为 Helper (避免打印当前代码行号)
	Log(args ...interface{})                   // 记录日志
	Logf(format string, args ...interface{})   // 格式化 记录日志
	Name() string                              // 查询测试名
	Setenv(key, value string)                  // 设置环境变量
	Skip(args ...interface{})                  // 记录日志+跳过测试
	SkipNow()                                  // 跳过测试
	Skipf(format string, args ...interface{})  // 格式化记录日志+跳过测试
	Skipped() bool                             // 查询测试是否被跳过
	TempDir() string                           // 返回一个临时目录
	//阻止用户实现的私有方法
	//接口,因此将来不会添加
	//违反Go 1兼容性。
	private()
}

实际上单元测试,性能测试和模糊测试都会使用接口。

接口定义中的 private() 方法是一个很巧妙的用法
目的是限定 testing.TB 接口全局唯一,防止用户自行实现 testing.TB接口
类似Java 的 final 关键字
因为 private 是小写的,包内可见,这样用户就无法实现 private 方法
用户也就无法实现 testing.TB 接口了

5. 单元测试的原理

单元测试的函数的入参是testing.T类型:

type T struct {
	common
	isParallel bool         // 是否需要并发
	isEnvSet   bool         // 是否设置环境变量
	context    *testContext //用于运行测试和子测试。
}

5.1 context 单元测试的调度

context 是调度单元测试的关键:

// testContext包含所有测试通用的所有字段。这包括
// 同步原语最多运行*个并行测试。
type testContext struct {
	match         *matcher   // 匹配器,用于管理测试名称匹配、过滤等
	deadline      time.Time  // 结束时间,防止测试运行过长
	mu            sync.Mutex // 互斥锁,用于控制 testContext 成员的互斥访问
	startParallel chan bool  // 用于向准备并行运行的测试发出信号的通道。
	running       int        // running是当前并行运行的测试数。这不包括等待子测验完成的测验。
	numWaiting    int        // numWaiting是等待并行运行的测试数。
	maxParallel   int        // maxParallel是并行标志的副本。
}

其初始化代码

func newTestContext(maxParallel int, m *matcher) *testContext {
	return &testContext{
		match:         m,
		startParallel: make(chan bool),
		maxParallel:   maxParallel,
		running:       1, // 设置主测试
	}
}

5.1.1 等待并发执行 testContext.waitParallel

如果一个测试使用t.Parallel()启动并发,那么这个测试并不是立即被并发执行,需要检查当前
并发执行的测试数是否达到最大值,这个检查工作统一放在testContext.waitParallel()中实现

func (c *testContext) waitParallel() {
    // 加锁
	c.mu.Lock()
	// 判断是否达到最大值
	if c.running < c.maxParallel {
	    // 如果没有达到最大值,那么 运行数++
		c.running++
		c.mu.Unlock()
		return
	}
	// 如果已经达到最大值,那么等待执行的数量++
	c.numWaiting++
	c.mu.Unlock()
	// 获取启动的令牌信号,需要注意,chan 是没有缓冲区的
	// 也就是会阻塞,直到有生产 的 chan ,才会解除阻塞
	<-c.startParallel
}

阻塞等待后面没有累加 running ,因为 running 表示的运行中的个数
阻塞解除,必然表示一个测试结束,当前测试开始,运行中的个数没有变化,所以不增加

5.1.2 并发测试结束 testContext.release

当并发测试结束后,会通过 release 方法释放一个信号,用于启动其他等待并发测试的函数

func (c *testContext) release() {
    // 加锁
	c.mu.Lock()
	// 如果没有等待启动的测试,那么直接将 running-- 
	if c.numWaiting == 0 {
		c.running--
		c.mu.Unlock()
		return
	}
	// 否则将等待的数量减1,然后生产一个启动的信号
	c.numWaiting--
	c.mu.Unlock()
	c.startParallel <- true // Pick a waiting test to be run.
}

5.2 测试执行 tRunner

func tRunner(t *T, fn func(t *T)) {
	t.runner = callerName(0) // 获取当前测试函数的名称
	//当这个goroutine完成时,要么是因为fn(t)
	//正常返回或由于触发测试失败
	//对运行时的调用。Goexit,记录持续时间并发送
	//表示测试完成的信号。
	defer func() {
		// 测试失败,那么将失败数+1
		if t.Failed() {
			atomic.AddUint32(&numFailed, 1)
		}
		// 如果测试惊慌失措,请在终止之前打印任何测试输出。
		err := recover()
		signal := true
		// 读锁定
		t.mu.RLock()
		// 获取完成状态
		finished := t.finished
		// 读锁定解锁
		t.mu.RUnlock()
		// 如果测试未完成,但是异常信息为空
		if !finished && err == nil {
			// 将错误信息赋值为空错误或空异常
			err = errNilPanicOrGoexit
			// 如果有父测试,当前是子测试
			for p := t.parent; p != nil; p = p.parent {
				p.mu.RLock()
				finished = p.finished
				p.mu.RUnlock()
				if finished {
					t.Errorf("%v: subtest may have called FailNow on a parent test", err)
					err = nil
					signal = false
					break
				}
			}
		}
		// 使用延迟调用以确保我们报告测试
		// 完成,即使清除函数调用t.FailNow。请参见第41355期。
		didPanic := false
		defer func() {
			if didPanic {
				return
			}
			if err != nil {
				panic(err)
			}
			//只有在没有恐慌的情况下才报告测试完成,
			//否则,测试二进制文件可以在死机之前退出
			//报告给用户。请参见第41479期。
			t.signal <- signal
		}()

		doPanic := func(err interface{}) {
			// 设置测试失败
			t.Fail()
			if r := t.runCleanup(recoverAndReturnPanic); r != nil {
				t.Logf("cleanup panicked with %v", r)
			}
			//在终止之前将输出日志刷新到根目录。
			for root := &t.common; root.parent != nil; root = root.parent {
				root.mu.Lock()
				// 计算时间
				root.duration += time.Since(root.start)
				d := root.duration
				root.mu.Unlock()
				root.flushToParent(root.name, "--- FAIL: %s (%s)\n", root.name, fmtDuration(d))
				if r := root.parent.runCleanup(recoverAndReturnPanic); r != nil {
					fmt.Fprintf(root.parent.w, "cleanup panicked with %v", r)
				}
			}
			didPanic = true
			panic(err)
		}
		if err != nil {
			doPanic(err)
		}

		t.duration += time.Since(t.start)
		// 如果有子测试,当前是父测试
		if len(t.sub) > 0 {
			// 停止测试
			t.context.release()
			// 释放平行的子测验。
			close(t.barrier)
			// 等待子测验完成。
			for _, sub := range t.sub {
				<-sub.signal
			}
			cleanupStart := time.Now()
			err := t.runCleanup(recoverAndReturnPanic)
			t.duration += time.Since(cleanupStart)
			if err != nil {
				doPanic(err)
			}
			// 如果不是并发的
			if !t.isParallel {
				// 等待开始
				t.context.waitParallel()
			}
		} else if t.isParallel { // 如果是并发的
			//仅当此测试以并行方式运行时才释放其计数 测验请参阅Run方法中的注释。
			t.context.release()
		}
		// 测试执行结束上报日志
		t.report()
		t.done = true
		// 如果有父测试,那么设置执行标志
		if t.parent != nil && atomic.LoadInt32(&t.hasSub) == 0 {
			t.setRan()
		}
	}()
	defer func() {
		if len(t.sub) == 0 {
			t.runCleanup(normalPanic)
		}
	}()

	t.start = time.Now()
	t.raceErrors = -race.Errors()
	fn(t)

	// code beyond here will not be executed when FailNow is invoked
	t.mu.Lock()
	t.finished = true
	t.mu.Unlock()
}

测试命令go test首先会触发RunTests方法
在这里插入图片描述

然后会根据传入参数进行启动
在这里插入图片描述

所以在 tRunner 中的 fn 就是 一个 for 循环,循环启动测试case
所以也就是说 tRunner 是一个公共的方法,不管是单元测试,还是性能测试,还是模糊测试,都会调用tRunner
这也是为何tRunner方法中混杂了性能测试,子测试的逻辑。
tRunner 传递一个经调度这设置过的 testing.T 参数和一个测试函数,执行时记录开始时间,
然后将testing.T 参数传入测试函数并同步等待结束。
tRunner在defer语句中记录测试执行耗时,并上报日志,最后发送结束信号。

5.3 启动测试 Run

在调用顺序中,调用tRunner的是Run

// 将运行f作为名为name的t的子测试。它在一个单独的goroutine中运行f
// 并且阻塞直到f返回或调用t。并行成为并行测试。
// 运行报告f是否成功(或者至少在调用t.Parallel之前没有失败)。
//
// Run可以从多个goroutine同时调用,但所有此类调用
// 必须在t的外部测试函数返回之前返回。
func (t *T) Run(name string, f func(t *T)) bool {
	// 将子测试的数量+1
	atomic.StoreInt32(&t.hasSub, 1)
	// 获取匹配的测试name
	testName, ok, _ := t.context.match.fullName(&t.common, name)
	// 如果没有配置,那么直接结束
	if !ok || shouldFailFast() {
		return true
	}
	//记录此调用点的堆栈跟踪,以便如果子测试
	//在单独的堆栈中运行的函数被标记为助手,我们可以
	//继续将堆栈遍历到父测试中。
	var pc [maxStackLen]uintptr
	// 获取调用者的函数name
	n := runtime.Callers(2, pc[:])
	t = &T{ // 创建一个新的 testing.T 用于执行子测试
		common: common{
			barrier: make(chan bool),
			signal:  make(chan bool, 1),
			name:    testName,
			parent:  &t.common,
			level:   t.level + 1,
			creator: pc[:n],
			chatty:  t.chatty,
		},
		context: t.context,
	}
	t.w = indenter{&t.common}
	if t.chatty != nil {
		t.chatty.Updatef(t.name, "=== RUN   %s\n", t.name)
	}
	//而不是在调用之前减少此测试的运行计数
	//tRunner并在之后增加它,我们依靠tRunner保持
	//计数正确。这样可以确保运行一系列顺序测试
	//而不会被抢占,即使它们的父级是并行测试。这
	//如果*parallel==1,则可以特别减少意外。
	go tRunner(t, f)
	if !<-t.signal {
		//此时,FailNow很可能是在
		//其中一个子测验的家长测验。继续中止链的上行。
		runtime.Goexit()
	}
	return !t.failed
}

Run函数启动一个单独的协程来运行名字为name的子测试f,并且会阻塞等待其执行结束,除非子测试f显式
调用t.Parallel将自己变为一个可并行的测试,最后返回 bool 类型的测试结果。
比如,挡在测试 func TestXxx(t *testing.T) 中调用 Run(name, f)时,
Run将启动一个名为TestXxx/name的子测试。
另外,所有的测试,包括 func TestXxx(t *testing.T) 自身,都是由testMain使用Run方法启动。
每启动一个子测试都会创建一个testing.T变量,该变量继承当前测试的部分属性,然后以新协程去执行,当前测试会在子测试结束后返回子测试的结果。
子测试退出条件要么是子测试执行结束,要么是子测试设置了Parallel,否则是异常退出。

5.4 启动并发测试 Parallel

Parallel方法将当前测试已加入并发队列

// 与此测试并行运行的并行信号(且仅与)
// 其他平行测试。当测试由于使用而多次运行时
// -test.count或-test.cpu,单个测试的多个实例从未在中运行
// 彼此平行。
func (t *T) Parallel() {
	// 重复调用
	if t.isParallel {
		panic("testing: t.Parallel called multiple times")
	}
	// 先设置环境变量
	if t.isEnvSet {
		panic("testing: t.Parallel called after t.Setenv; cannot set environment variables in parallel tests")
	}
	t.isParallel = true
	//我们不想把等待串行测试的时间包括在内
	//在测试持续时间内。记录到目前为止经过的时间,并重置
	//计时器之后。
	t.duration += time.Since(t.start)
	//添加到要由父级发布的测试列表中。
	t.parent.sub = append(t.parent.sub, t)
	t.raceErrors += race.Errors()
	if t.chatty != nil {
		//不幸的是,即使PAUSE表示命名测试是*no
		//运行时间较长*,cmd/test2json将其解释为更改活动测试
		//用于日志解析。我们可以修复cmd/test2json,但是
		//不会修复已经shell的第三方工具的现有部署
		//向外扩展到cmd/test2json的旧版本——因此仅修复cmd/test1json
		//目前还不够。
		t.chatty.Updatef(t.name, "=== PAUSE %s\n", t.name)
	}
	// 当前测试即将进入并发模式,表示测试结束,这样父测试就不会等待并退出 Run
	t.signal <- true   // 释放调用测试。
	<-t.parent.barrier // 等待父测试完成。
	// 阻塞等待并发调度
	t.context.waitParallel()
	if t.chatty != nil {
		t.chatty.Updatef(t.name, "=== CONT  %s\n", t.name)
	}
	// 重置时间,第二段耗时
	t.start = time.Now()
	t.raceErrors += -race.Errors()
}

在testContext中,启动一个并发测试测试后,当并发数达到最大时,并不会马上开始执行,
而是需要等待一个测试执行完成后,才会启动一个等待的测试,等待时间不会计入测试的执行耗时中。
在Run里面,启动一个测试后,会等待测试执行完成后,才会启动下一个。 如果是并发,那么就不需要等待了。
通过 t.signal 通知父测试,父测试从Run中唤醒,继续执行。
父测试唤醒后继续执行,结束后进入defer,在defer中将启动所有子测试,并等待子测试执行结束。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:/a/758100.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

<电力行业> - 《第9课:输电(二)》

4 输送电能流程 输送电能总共有&#xff1a;发电站→升压变压器→高压输电线→降压变压器→用电单位等五个流程。 电力工业初期&#xff0c;发电厂建在电力用户附近&#xff0c;直接向用户送电&#xff0c;所以那个时候只有发电和用电两个环节。 随着电力生产规模和负荷中心规…

基于MongoDB的电影影评分析

项目源码及资料 项目介绍 1、从豆瓣网爬取Top10的电影数据 爬取网址: https://movie.douban.com/top250 1.1 爬取Top10的影视信息 mv_data [] i 0 for x in soup.select(.item):i 1mv_name re.search(>([^<])<, str(x.select(.info > .hd > a > .tit…

ASP.NET Core 6.0 使用 Action过滤器

Action过滤器 在ASP.NET Core中&#xff0c;Action过滤器用于在执行Action方法之前或之后执行逻辑。你可以创建自定义的Action过滤器来实现这一点。 继承 ActionFilterAttribute 类&#xff1a; [TypeFilter(typeof(CustomAllActionResultFilterAttribute))]public IActionRe…

Pc端多功能视频混剪工具/便携版打开即用

PC便携版 视频批量剪辑大师&#xff0c;全自动剪辑神器&#xff0c;会打字就能做视频 多功能&#xff0c;视频混剪&#xff0c;视频配音&#xff0c;文字生成语音&#xff0c;图片合成视频&#xff0c;自动识别音频并生成字幕等功能 链接&#xff1a;https://pan.baidu.com/…

【原创实现 设计模式】Spring+策略+模版+工厂模式去掉if-else,实现开闭原则,优雅扩展

1 定义与优点 1.1 定义 策略模式&#xff08;Strategy Pattern&#xff09;属于对象的⾏为模式。他主要是用于针对同一个抽象行为&#xff0c;在程序运行时根据客户端不同的参数或者上下文&#xff0c;动态的选择不同的具体实现方式&#xff0c;即类的行为可以在运行时更改。…

代码随想录算法训练营第四十七天| 188.买卖股票的最佳时机IV ,309.最佳买卖股票时机含冷冻期 ,714.买卖股票的最佳时机含手续费

188. 买卖股票的最佳时机 IV - 力扣&#xff08;LeetCode&#xff09; class Solution {public int maxProfit(int k, int[] prices) {int[][] dp new int[prices.length][2*k];for(int i0;i<2*k;i){if(i%2 0){dp[0][i] -prices[0];}else{dp[0][i] 0;} }for(int i1;i…

C语言 | Leetcode C语言题解之第206题反转链表

题目&#xff1a; 题解&#xff1a; struct ListNode* reverseList(struct ListNode* head) {if (head NULL || head->next NULL) {return head;}struct ListNode* newHead reverseList(head->next);head->next->next head;head->next NULL;return newHea…

使用Java连接数据库并且执行数据库操作和创建用户登录图形化界面(3)专栏里有上两步的源代码

创建用户登录程序&#xff0c;验证用户账号和密码信息是否在数据库student中的用户表tb_account中存在。用户登录界面如下图所示&#xff1a; 当单击“登录”按钮时&#xff0c;处理以下几种情况&#xff1a; &#xff08;1&#xff09;用户名未输入&#xff0c;提示用户名不能…

Apache Ranger 2.4.0 安装部署

1、安装ranger admin 2、源码编译Ranger wget https://www.apache.org/dist/ranger/2.4.0/apache-ranger-2.4.0.tar.gz tar zxvf apache-ranger-2.4.0.tar.gz cd apache-ranger-2.4.0 mvn -Pall clean mvn clean package -DskipTests maven settting可以设置阿里云进行资源下载…

【漏洞复现】金和OA 未授权访问

【产品介绍】 金和OA协同办公管理系统C6软件&#xff08;简称金和OA&#xff09;&#xff0c;本着简单、适用、高效的原则&#xff0c;贴合企事业单位的实际需求&#xff0c;实行通用化、标准化、智能化、人性化的产品设计&#xff0c;充分体现企事业单位规范管理、提高办公效…

fiddler抓包工具

概念 概念&#xff1a; Fiddler是一个http协议调试代理工具&#xff0c;它能够记录并检查所有你的电脑和互联网之间的http通讯。 http&#xff1a;不加密&#xff0c;端口为80 https&#xff1a;加密&#xff0c;端口为443 原理&#xff1a; 其实就在访问服务器时&#xff0…

Python逻辑控制语句 之 判断语句--if语句的基本结构

1.程序执行的三大流程 顺序 分支&#xff08;判断&#xff09; 循环 2.if 语句的介绍 单独的 if 语句,就是 “如果 条件成⽴,做什么事” 3.if 语句的语法 if 判断条件: 判断条件成立&#xff0c;执行的代码…

reactor网络模型的原理与实现

一、rector网络模型 对高并发编程&#xff0c;网络连接上的消息处理&#xff0c;可以分为两个阶段&#xff1a;等待消息准备好、消息处理。当使用默认的阻塞套接字时&#xff0c;往往是把这两个阶段合而为一&#xff0c;这样操作套接字的代码所在的线程就得睡眠来等待消息准备好…

【工具分享】Nuclei

文章目录 NucleiLinux安装方式Kali安装Windows安装 Nuclei Nuclei 是一款注重于可配置性、可扩展性和易用性的基于模板的快速漏洞验证工具。它使用 Go 语言开发&#xff0c;具有强大的可配置性、可扩展性&#xff0c;并且易于使用。Nuclei 的核心是利用模板&#xff08;表示为简…

【文档智能】DLAFormer:端到端的解决版式分析、阅读顺序方法

前言 前面文章介绍到&#xff0c;文档智能中版式分析(DLA)&#xff08;《【文档智能 & RAG】RAG增强之路&#xff1a;增强PDF解析并结构化技术路线方案及思路》&#xff09;、阅读顺序&#xff08;《【文档智能】符合人类阅读顺序的文档模型-LayoutReader及非官方权重开源…

抖音短视频seo矩阵系统源代码开发系统架构及功能解析

一、矩阵运营系统开发背景: 在数字化经营的浪潮中&#xff0c;抖音开放平台以其独特的影响力和广泛覆盖的用户群体&#xff0c;成为了企业不可忽视的数字营销阵地。然而&#xff0c;企业在享受抖音带来的巨大流量红利的同时&#xff0c;也面临着多账号管理运营协同效率低下、数…

Ollama中文版部署

M1部署Ollama Ollama中文网站: Featured - 精选 - Ollama中文网 下载网址: Download Ollama on macOS 安装后运行llma3模型: ollama run llama3:8b 界面使用: GitHub - open-webui/open-webui: User-friendly WebUI for LLMs (Formerly Ollama WebUI) 部署open-webui: do…

C++知识点总结 (02):C++中的语句(简单语句、条件语句、迭代语句、跳转语句、异常处理语句、try语句等)

文章目录 1、简单语句(1)空语句(2)复合语句 2、条件语句3、迭代语句(1)常规for循环(2)范围for循环(3)while和do...while 4、跳转语句(1)break(2)continue(3)goto 5、异常处理语句(1)标准异常(2)throw抛出异常 6、try语句 1、简单语句 (1)空语句 ; (2)复合语句 用花括号括起来的…

【实施】系统实施方案(软件方案Word)

软件实施方案 二、 项目介绍 三、 项目实施 四、 项目实施计划 五、 人员培训 六、 项目验收 七、 售后服务 八、 项目保障措施 软件开发全套资料获取&#xff1a;私信或者进主页获取。 软件产品&#xff0c;特别是行业解决方案软件产品不同于一般的商品&#xff0c;用户购买软…

JS(JavaScript)二级菜单级联案例演示

天行健&#xff0c;君子以自强不息&#xff1b;地势坤&#xff0c;君子以厚德载物。 每个人都有惰性&#xff0c;但不断学习是好好生活的根本&#xff0c;共勉&#xff01; 文章均为学习整理笔记&#xff0c;分享记录为主&#xff0c;如有错误请指正&#xff0c;共同学习进步。…