In the previous article I described how to start and manage processes. The next step is to implement a cli utility that uses the resulting code. The task of implementing such a utility is not very difficult, but this is how to test it - this is already a more interesting. In this article I will describe one of the ways of testing that involves reading the output logs.
Flags
To start the necessary process it must be something passed to the utility. In addition, configuration values are required to start, for example
t
Time to live of lock.k
Lock key namel
Logging level
and probably something else will be added in the future. The final kind of launch command would like to see suchdjob [options] - command
.Command
is the command (with arguments to run).--
should separate the arguments of the utility from the arguments of the program to run.
There are several excellent solutions for working with command-line arguments, such asspf13/cobra orurfave/cli. But they’re good for building an interface out of a lot of commands, and for one (as in my example) they’re redundant. So I used theflag library.
I have defined the following structure with arguments:
typeCliParamsstruct{key*stringcmdArgs[]stringlogLevel*stringttl*int}
And made a function that parses arguments:
funcparseCliParams(args[]string)(*CliParams,*flag.FlagSet,error){flags:=flag.NewFlagSet("djob",flag.ContinueOnError)params:=&CliParams{key:new(string),cmdArgs:[]string{},logLevel:new(string),ttl:new(int),}flags.StringVar(params.key,"k","","Lock key. Default: filename")flags.StringVar(params.logLevel,"l","error","Log level: debug|info|warn|error|fatal. Default: error")flags.IntVar(params.ttl,"t",60,"Ttl in seconds. Default: 60")flags.Usage=func(){fmt.Fprint(os.Stderr,"Usage: djob [OPTIONS] -- [command with arguments and options]\n")flags.PrintDefaults()}iferr:=flags.Parse(args);err!=nil{returnnil,flags,fmt.Errorf("parse flags: %v",err)}fork,arg:=rangeargs{ifarg=="--"{params.cmdArgs=args[k+1:]break}}iflen(params.cmdArgs)==0{returnnil,flags,errors.New("command is required")}iflen(*params.key)==0{params.key=¶ms.cmdArgs[0]}returnparams,flags,nil}
To work with arguments I need to createflagset. There may be a few of these sets, but I’m good with one now.
I’ve identified the flags I wrote about earlier. Define them the types and pass the address of the variable (the field in the structure) where the parsed value should be placed. In order to make it easier for the user to understand how to use the tool I defined the functionUsage
. It prints in the output information, as well as descriptions of all flags and their default values.
Then I need to call theflagset methodParse([]string)
. This method gets the user’s arguments on the input. Next it will beos.Args
. I deliberately made this feature independent ofos.Args
for the convenience of testing. Flags that I have not specified in the configuration will be ignored.
Next I found the flag--
and put everything after it as there is in thecmdArgs
parameter. This is the command that the user wants to run. In addition, I made some minimal validation and at the output I got the structure command to run and parameters.
Logging
Before moving to testing I will talk about logging. Logging is another component that matters for testing implementation. In any case, it is required in almost any program, including the utility I describe.
I used the librarylogrus, because it has the ability to intercept logs with hooks. And I used this feature to read logs during testing. At the same time, the code of the main function itself does not have to adapt to tests.
typetestHookstruct{channelchan*log.Entry}func(hook*testHook)Fire(entry*log.Entry)error{hook.channel<-entryreturnnil}func(hook*testHook)Levels()[]log.Level{returnlog.AllLevels}funcnewTestLogger()(*log.Logger,chan*log.Entry){logger:=log.New()logger.Out=ioutil.Discardlogger.Level=log.DebugLevelentryChan:=make(chan*log.Entry,0)logger.Hooks.Add(&testHook{channel:entryChan})returnlogger,entryChan}
I created a new logger with the output turned off and added atestHook
to it. It instantly sends the logger to the channel instead of putting it into the console. Not only the messages themselves, but the whole structure that contains the full information, including the levels of the log entry, time, etc. It is all very convenient.
Main function
Now I can go directly to the main function. In it, I need to callparseCliParams
and pass theos.Args
there, and then start the process as I described in the previous article.
However, I have two obstacles:
- I can’t test main function
- I don’t have the ability to pass various arguments in tests other than
os.Args
(or it will be very ugly and unreliable)So I implementedExec()
:
funcExec(osArgs[]string,logger*log.Logger)int{ctx,cancel:=context.WithCancel(context.Background())defercancel()params,flags,err:=parseCliParams(osArgs)iferr!=nil{logger.Errorf("parse arguments: %v\n",err.Error())flags.Usage()return2}lvl,err:=logrus.ParseLevel(*params.logLevel)iferr!=nil{logger.Fatalf("ivalid log level %s\n",*params.logLevel)}logger.SetLevel(lvl)memLock:=internal.NewMemoryLock()locked,err:=memLock.Lock(ctx,*params.key,*params.clientId)iferr!=nil{logger.Errorf("Didn't lock: %v\n",err)}if!locked{logger.Info("Already locked\n")return2}cmd,err:=internal.ExecCmd(ctx,params.cmdArgs)iferr!=nil{logger.Errorf("exec command: %v\n",err)return1}logger.Info("started")deferfunc(){unlocked,err:=memLock.UnLock(ctx,*params.key,*params.clientId)iferr!=nil{logger.Errorf("can't unlock: %v",err)}if!unlocked{logger.Info("Already unlocked")}}()exitCode,err:=internal.WaitCmd(ctx,cmd)iferr!=nil{logger.Fatalf("wait command: %v",err)return1}returnexitCode}
memLock := internal.NewMemoryLock()
This is a simple implementation of locks in memory. In this example, it is only necessary for the code to work. More locks I will consider in the following articles and now their implementation does not matter.
cmd, err := internal.ExecCmd(ctx, params.cmdArgs)
Wrap on the function of starting a new process, which I discussed in the last article. It is not necessary to know the content of this function to understand the material of this article.
exitCode, err := internal.WaitCmd(ctx, cmd)
This function waits for the process running with ExecCmd to complete. It returns the response code. In this case too, it is not necessary to know the content of the function.
I will go ahead and say that all the received code of the utility will be pushed on GitHub. But this is after I go through all the parts of the implementation step by step.
funcmain(){logger:=log.New()os.Exit(Exec(os.Args,logger))}
Testing
Finally I came to the most important thing for which this article - testing. I did all the necessary preparation.
I needed a script to run in the tests. It has to be a program that can run for a while and I need to be able to set that time.
#!/usr/bin/env bashtrap"echo SIGINT; exit" SIGINTfor((i=1; i<$1; i++))doecho"step$i"sleep$2doneecho"finish"
In the first argument I set the number of iterations, and in the second time the iteration. Everything is simple and this should be enough.
Next is the test itself.
funcTestRunJob(t*testing.T){logger,entryChan:=newTestLogger()testCases:=[]struct{namestringargs[]stringlogEntries[]*struct{levellog.Levelmessagestring}}{{name:"first",args:[]string{"djob","-k","key","-c","client1","l","info","--","../tests/script.sh","2","2"},logEntries:[]*struct{levellog.Levelmessagestring}{{level:log.InfoLevel,message:"start",},{level:log.InfoLevel,message:"finish",},},},}for_,tt:=rangetestCases{t.Run(tt.name,func(t*testing.T){ctx,cancel:=context.WithCancel(context.Background())defercancel()gofunc(){entryIndex:=0for{select{case<-ctx.Done():returncaseentry:=<-entryChan:ifentryIndex>len(tt.logEntries){t.Errorf("got more than expected (%d) log messages",len(tt.logEntries))}iftt.logEntries[entryIndex].level!=entry.Level{t.Errorf("Expected level: %s, actual: %s",tt.logEntries[entryIndex].level,entry.Level)}iftt.logEntries[entryIndex].message!=entry.Message{t.Errorf("Expected log message: %s, actual: %s",tt.logEntries[entryIndex].message,entry.Message)}entryIndex++}}}()cmd.Exec(tt.args,logger)})}}
Let me describe the whole code step by step.
testCases:=[]struct{namestringargs[]stringlogEntries[]*struct{levellog.Levelmessagestring}}
inlogEntries
I specified expected entries in logs. I am only interested in message and level.
gofunc(){entryIndex:=0for{select{case<-ctx.Done():returncaseentry:=<-entryChan:ifentryIndex>len(tt.logEntries){t.Errorf("got more than expected (%d) log messages",len(tt.logEntries))}iftt.logEntries[entryIndex].level!=entry.Level{t.Errorf("Expected level: %s, actual: %s",tt.logEntries[entryIndex].level,entry.Level)}iftt.logEntries[entryIndex].message!=entry.Message{t.Errorf("Expected log message: %s, actual: %s",tt.logEntries[entryIndex].message,entry.Message)}entryIndex++}}}()
In the test I run a go routine. It listens to the channel with logs. And then I just make a check whether the entry is in the log, which I expect or not.
cmd. Exec(tt.args, logger)
I run the main function with the necessary arguments and configured logger.
Conclusion
I described how it is possible to organize the testing of the cli utility with the help of logger. Next you only need to make all the necessary test cases. It was the second article in the Distributed Locks series. Next, I’ll move on to the implementation of the locks themselves and I’ll put the result on GitHub.
Top comments(0)
For further actions, you may consider blocking this person and/orreporting abuse