onsi / ginkgo

A Modern Testing Framework for Go
http://onsi.github.io/ginkgo/
MIT License
8.22k stars 650 forks source link

[QUESTION] How can you use a flag value in different files within the same package? #1258

Closed Mo0rBy closed 1 year ago

Mo0rBy commented 1 year ago

I want to set a flag when executing my tests from the CLI. The value of this flag is used within a BeforeSuite() and I also want to use it in a bunch of other files to create some variables that store my expected values. These values are then imported into a file that contains some specs.

First, here is my directory structure:

app_name/
|-- expected/
|   |-- deployments/
|   |   |-- app_service_A.go
|   |   |-- app_service_B.go
|   |   `-- app_service_C.go
|   `-- services/
|       |-- app_service_A.go
|       |-- app_service_B.go
|       `-- app_service_C.go
|-- deployments_test.go
|-- services_test.go
`-- app_suite_test.go

I am doing some kubernetes testing, so I am creating an expected object (these object can be fairly large and complex) and then importing the expected object into my file containing the specs and using it for assertions against the actual object which I get in a BeforeEach(). The expected directory is where I create an object. If we create a new microservice, we can just copy/paste one of these files and then change some values, so I believe this structure is good, but if not, please let me know.

Here is my app_suite_test.go file where I use the init() function and the BeforeSuite():

var context_name, namespace string
var kube_options *k8s.KubectlOptions

func init() {
    flag.StringVar(&context_name, "context-name", "my-kube-context", "The kube context name to use")
    flag.StringVar(&namespace, "namespace", "", "The cluster namespace to test against")
}

func TestMyCluster(t *testing.T) {
    RegisterFailHandler(Fail)
    RunSpecs(t, "App-Name Suite")
}

var _ = BeforeSuite(func() {
    kube_options = k8s.NewKubectlOptions(context_name, "", namespace)
})

I want to import my namespace value OR my kube_options value from app_suite_test.go into the files within the expected directory. I have tried importing, but GO doesn't like it. I always get an undefined error, and I believe this is because the files in the expected directory are deeper in the file tree than app_suite_test.go and you can only import FROM files that are shallower in the file tree (because I can import the object I need from expected into deployments_test.go with no problems). This is just a guess and I could be wrong.

And here is an example of where I try to use this value to create an expected value within a file in my expected directory (there are many more variables that are created in this file and used to create 1 object that is imported into my specs. This is just 1 variable of about 15):

var service_A_expected_deployment_labels = map[string]string{
    "app.kubernetes.io/managed-by":     "Helm",
    "helm.toolkit.fluxcd.io/name":      service_A_service_name,
    "helm.toolkit.fluxcd.io/namespace": <I want the 'namespace' value here>,
}

I've tried using environment variables which results in code that looks like this:

var _ = BeforeSuite(func() {
    kube_options = k8s.NewKubectlOptions(context_name, "", namespace)
    os.Setenv("namespace", namespace)
})
var  service_A_expected_deployment_labels = map[string]string{
    "app.kubernetes.io/managed-by":     "Helm",
    "helm.toolkit.fluxcd.io/name":       service_A_service_name,
    "helm.toolkit.fluxcd.io/namespace": os.Getenv("namespace"),
}

Using environment variables does actually work (YAY)! However, when running ginkgo -p -- --namespace="my_namespace", it doesn't seem to set the environment variable correctly, or something else is happening because the specs are using multiple cores or something? I really don't know.

I would very much prefer to be able to use parallel testing. My test framework is very new and just getting started so it takes about 5 seconds to run all tests, but it will be growing at a very fast pace, so parallel testing is a must have for my case.

Mo0rBy commented 1 year ago

I've done some more research and it looks like theres no way to implement this kind of functionality. Golang does no allow imports to subpackages to prevent infinite import loops so I don't think there is anyway to get this value to be access globally by all packages and sub packages.

onsi commented 1 year ago

Hey! First off - I'd like to encourage you to explore Go's notion of packages a bit more so you can build a stronger mental model for how they work. In brief - you're correct that Go does not allow for circular imports. Note that this isn't related to how deep or shallow a package is - it's simply a statement that if package A imports package B then package B is not allowed to import package A.

Moreover, the way tests work in Go is a bit tricky to understand at first. Normally all files in a package are compiled into the same package (e.g. all your app_service_*.go files get compiled into the expected/deployments package. However there is one special case scenario that Go treats differently: tests. Any files that end with *_test.go are treated differently. They are never compiled into a standalone importable package. Instead, they are only compiled when the go test command is invoked (or when you run ginkgo - which is simply using go test under the hood) to generate a new, distinct, temporary "testing" package that is then executed to run the tests. I try to cover this in a bit more detail on the Ginkgo docs as it can be confusing at first. An important thing to understand is that you cannot import test packages. So any values defined and exported in your test packages can't be accessed elsewhere.

With all that said there is a way to do what you want to do. I typically create a new package (e.g. config) that defines the flags, has the init() function and then exports variables/functions/etc. for use by any consumers. This breaks the import cycle and allows you to use the config package wherever you need:

app_name/
|-- config/
|   |-- config.go
|-- expected/
|   |-- deployments/
|   |   |-- app_service_A.go
|   |   |-- app_service_B.go
|   |   `-- app_service_C.go
|   `-- services/
|       |-- app_service_A.go
|       |-- app_service_B.go
|       `-- app_service_C.go
|-- deployments_test.go
|-- services_test.go
|-- app_suite_test.go
//config.go

var context_name, namespace string

func init() {
    flag.StringVar(&context_name, "context-name", "my-kube-context", "The kube context name to use")
    flag.StringVar(&namespace, "namespace", "", "The cluster namespace to test against")
}

func KubeOptions() *k8s.KubectlOptions {
    return k8s.NewKubectlOptions(context_name, "", namespace)
}

now you can import ".../config" from any code that needs it and use config.KubeOptions() to get the relevant options object.

Lastly - the environment variable approach you attempted should have worked. It might be an indication that there is something about your set up that doesn't play well with how Ginkgo expects parallel suites to be set up. I can help with that if you'd like.

Mo0rBy commented 1 year ago

Thanks for your reply @onsi ! A few follow up questions.

You've created the KubeOptions() function and then that returns a new object every time it is called. If I'm using this lots, will this affect the performance of my test framework? Would it be more ideal to create a var Kube_options = k8s.NewKubectlOptions(context_name, "", namespace) and then import that var object?

And am I correct in saying that I don't need to use the BeforeSuite() because this config.go is not a test file and is compiled before the _test.go files (just as you have described). For this reason, the object I want will be created and usable in all my other test packages?

onsi commented 1 year ago

hey @Mo0rBy

You've created the KubeOptions() function and then that returns a new object every time it is called. If I'm using this lots, will this affect the performance of my test framework? Would it be more ideal to create a var Kube_options = k8s.NewKubectlOptions(context_name, "", namespace) and then import that var object?

I very much doubt you'll have any measurable performance impact, but yes you can define a variable however you'll want to define it after the flags are parsed which can be tricky to get right. I'd just stick with a function call personally.

And am I correct in saying that I don't need to use the BeforeSuite() because this config.go is not a test file and is compiled before the _test.go files (just as you have described). For this reason, the object I want will be created and usable in all my other test packages?

The objects defined in the new config package will be accessible to all specs, yes. You won't need to use BeforeSuite for this purpose any more (however if there is some other kind of set up your BeforeSuite is doing you'll obviously need to keep it around for that!)

Mo0rBy commented 1 year ago

I attempted to do what I've described above and it doesn't seem to work. I had a utils package already, so I decided to put my init() function in there.

package utils

import (
    "flag"
    "fmt"

    "github.com/gruntwork-io/terratest/modules/k8s"
)

var context_name, namespace string
var Kube_options *k8s.KubectlOptions

func init() {
    flag.StringVar(&context_name, "context-name", "my-kube-context", "The kube context name to use")
    flag.StringVar(&namespace, "namespace", "", "The cluster namespace to test against")

    fmt.Println("LOOK HERE!")
    fmt.Println(namespace)
    Kube_options = k8s.NewKubectlOptions(context_name, "", namespace)
}

I was getting an error from my tests stating that the an empty namespace may not be set, so I tried to print the value here and it is blank.

Setting it up exactly how you have suggest works beautifully, but again, I'm concerned about creating a new object every time I call the KubeOptions() function

Mo0rBy commented 1 year ago

Tried this too and that doesn't work either.

var context_name, namespace string

func init() {
    flag.StringVar(&context_name, "context-name", "deva-psdt", "The kube context name to use")
    flag.StringVar(&namespace, "namespace", "", "The cluster namespace to test against")
}

var Kube_options = k8s.NewKubectlOptions(context_name, "", namespace)

I'll just stick with the function way you described. Thanks again!!

onsi commented 1 year ago

@Mo0rBy - yep this is what I meant by "'ll want to define it after the flags are parsed" - the flag.StringVar statements simply define the flags. They are parsed later by Go's testing runtime. I really wouldn't worry about the cost of creating an object in Go (or any modern language for that matter) and would encourage you to measure the impact to allay your concerns!

With that said, you can memoize kubeOptions:

package utils

import (
    "flag"
    "fmt"

    "github.com/gruntwork-io/terratest/modules/k8s"
)

var context_name, namespace string
var kubeOptions *k8s.KubectlOptions //note this is not exported

func init() {
    flag.StringVar(&context_name, "context-name", "my-kube-context", "The kube context name to use")
    flag.StringVar(&namespace, "namespace", "", "The cluster namespace to test against")

func KubeOptions() *k8s.KubectlOptions {
    if kubeOptions == nil {
        kubeOptions = k8s.NewKubectlOptions(context_name, "", namespace)
    }
    return kubeOptions
}
Mo0rBy commented 1 year ago

I've done some more playing around and noticed that your first suggestion with the function KubeOptions() seems to work the best, but it doesn't put the value into my expected files.

app_name/
|-- config/
|   |-- config.go
|-- expected/
|   |-- deployments/    (KubeOptions values do not go in here correctly, I still get empty strings like before)
|   |   |-- app_service_A.go
|   |   |-- app_service_B.go
|   |   `-- app_service_C.go
|   `-- services/
|       |-- app_service_A.go
|       |-- app_service_B.go
|       `-- app_service_C.go
|-- deployments_test.go    (KubeOptions values goes in here correctly, nice)
|-- services_test.go
|-- app_suite_test.go

Example of a var object inside a file in teh deployments directory:

var service_A_expected_deployment_labels = map[string]string{
    "app.kubernetes.io/managed-by":     "Helm",
    "helm.toolkit.fluxcd.io/name":      service_A_service_name,
    "helm.toolkit.fluxcd.io/namespace": utils.KubeOptions().Namespace,  // This is an empty string, but it shouldn't be
}

The *k8s.KubectlOptions has a Namespace field.

Mo0rBy commented 1 year ago

Just tried this as well and got the same result:

var context_name, namespace string

func init() {
    flag.StringVar(&context_name, "context-name", "my-kube-context", "The kube context name to use")
    flag.StringVar(&namespace, "namespace", "", "The cluster namespace to test against")
}

func KubeOptions() *k8s.KubectlOptions {
    return k8s.NewKubectlOptions(context_name, "", namespace)
}

func Namespace() string {
    return namespace
}
var service_A_expected_deployment_labels = map[string]string{
    "app.kubernetes.io/managed-by":     "Helm",
    "helm.toolkit.fluxcd.io/name":      service_A_service_name,
    "helm.toolkit.fluxcd.io/namespace": utils.Namespace(),  // This is an empty string, but it shouldn't be
}
onsi commented 1 year ago

hey - i'd suggest reading the go docs on the init function, this StackOverflow post is useful too. Here's how things work:

  1. Go compiles your code
  2. Each package is loaded up and all top-level variables are initialized and functions are defined
  3. Any init() functions are called
  4. Go's testing runtime parses the flags
  5. Your tests start running

The problem you are running into is that you are trying to define a top-level variable that has access to the parsed flags. This is not possible as 3 and 4 run after 2. If you want to use variables like this you'll need something somewhat ugly like this:

var service_A_expected_deployment_labels map[string]string

func InitializeVariables() {
    service_A_expected_deployment_labels = map[string]string{
    "app.kubernetes.io/managed-by":     "Helm",
    "helm.toolkit.fluxcd.io/name":      service_A_service_name,
    "helm.toolkit.fluxcd.io/namespace": utils.Namespace(),  // This is an empty string, but it shouldn't be
    }
}

and then call InitializeVariables yourself in a BeforeSuite (i.e. after #4) or something like that.

Mo0rBy commented 1 year ago

Ah right ok, well I'll close this issue now as it's not really a Ginkgo thing, it's more about my misunderstanding or lack of knowledge about how GO and it's testing actually works. When I find a nice way that works, I'll still post it here, just to help anyone that finds it.

Thanks again fro your help @onsi and I really like the framework!