-
Notifications
You must be signed in to change notification settings - Fork 504
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
services/horizon: Refactors integration test framework to allow custom parameter testing. #3741
Conversation
9513b26
to
8e9d926
Compare
e745a82
to
822f1f9
Compare
}) | ||
defer os.RemoveAll(STORAGE_PATH) | ||
|
||
suite.Exits(func() { test.StartHorizon() }) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't understand what's happening here. Could you explain what suite.Exits is doing and why it's necessary to implement the control flow in this way?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
As I understand it, (correct me if I'm wrong @Shaptic) we need this because our config is injected via global variables, and ad-hoc places in the code fetching environment variables. So to run the app with different configs we have to launch a whole new subprocess...
If we had a more controlled way of passing config into horizon we could do something like:
import "github.com/services/horizon/cmd"
c := cmd.RootCmd
c.SetEnv(map[string]string{"ENV_VARS_HERE": "1"}) // Not sure how to do this with cobra
c.SetArgs([]string{"db", "migrate", "up"})
assert.NoError(t, c.Execute()) // or assert.Error(t, c.Execute(), "whatever we expect")
But yeah the control flow around the subprocess thing in Exits
is quite weird either way.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You're almost there, @paulbellamy.
The main problem is that Horizon dies via log.Fatal
if the configuration options are invalid. Since the purpose of this framework is exactly to test whether or not Horizon treats invalid combinations as invalid (and that it treats valid combinations like it should), we basically need a way to say "did Horizon die as expected?" Since log.Fatal
kills the process, the test also immediately dies (we do use c.Execute()
in the integration tests [refer to Test.StartHorizon()
]) at the corresponding cmd.Execute()
call.
Thus, we need this (hacky, but functional) suite.Exits()
approach to detect Horizon exiting expectedly by launching the entire test as a subprocess.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, I'm saying that, in an ideal world, only place in the codebase that should do log.Fatal
, or os.Exit
is func main()
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sure, agreed! But that's a bigger refactor than is in scope here, unfortunately.
The alternative is to run the entirety of Horizon as a subprocess rather than "natively" in a go func() {}
; that refactor may actually be "better" in a way because it enables us to test subcommands, too (e.g. migrate
).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit, should this bit about log.Fatal and it dying be a comment on the Exits
method?
}) | ||
defer os.RemoveAll(STORAGE_PATH) | ||
|
||
suite.Exits(func() { test.StartHorizon() }) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
As I understand it, (correct me if I'm wrong @Shaptic) we need this because our config is injected via global variables, and ad-hoc places in the code fetching environment variables. So to run the app with different configs we have to launch a whole new subprocess...
If we had a more controlled way of passing config into horizon we could do something like:
import "github.com/services/horizon/cmd"
c := cmd.RootCmd
c.SetEnv(map[string]string{"ENV_VARS_HERE": "1"}) // Not sure how to do this with cobra
c.SetArgs([]string{"db", "migrate", "up"})
assert.NoError(t, c.Execute()) // or assert.Error(t, c.Execute(), "whatever we expect")
But yeah the control flow around the subprocess thing in Exits
is quite weird either way.
runParameterMatrix(test, []ValidatorFunc{ | ||
func() { validateCaptiveCoreDiskState(test, STORAGE_PATH) }, | ||
func() { validateNoBucketDirPath(test, STORAGE_PATH) }, | ||
}) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'd do these as two separate tests, and extract the setup (which is the common part) instead of extracting the test (which is the unique part).
// Should this be a tempdir?
const storagePath string = "./test_captive-core-works"
var defaultCaptiveCoreConfig = map[string]string{
"captive-core-storage-path": storagePath,
"captive-core-reuse-storage-path": "true",
horizon.StellarCoreBinaryPathName: os.Getenv("CAPTIVE_CORE_BIN"),
horizon.CaptiveCoreConfigPathName: "./captive-core.toml",
horizon.StellarCoreURLFlagName: "",
horizon.StellarCoreDBURLFlagName: "",
}
func TestCaptiveCoreCreatesExpectedDirsOnLaunch(t *testing.T) {
// ensure that running Captive Core creates a
// sensible directory structure.
//
// Pass "rootDirectory" set to whatever it is you pass to
// "--captive-core-storage-path".
if _, ok := os.LookupEnv("HORIZON_INTEGRATION_ENABLE_CAPTIVE_CORE"); !ok {
t.Skip() // explained above
}
cleanup := launchHorizon(t, defaultConfig)
defer cleanup()
storageDir := path.Join(rootDir, "captive-core")
coreConf := path.Join(storageDir, "stellar-core.conf")
assert.DirExists(t, rootDir)
assert.DirExists(t, storageDir)
assert.FileExists(t, coreConf)
}
func TestCaptiveCoreConfigExcludesBucketDirPathEntry(t *testing.T) {
// ensure the Captive Core auto-generated configuration
// file doesn't contain the BUCKET_DIR_PATH entry, which is forbidden when using
// Captive Core.
//
// Pass "rootDirectory" set to whatever it is you pass to
// "--captive-core-storage-path".
if _, ok := os.LookupEnv("HORIZON_INTEGRATION_ENABLE_CAPTIVE_CORE"); !ok {
t.Skip() // explained above
}
cleanup := launchHorizon(t, defaultConfig)
defer cleanup()
coreConf := path.Join(rootDir, "captive-core", "stellar-core.conf")
assert.FileExists(t, coreConf)
result, err := ioutil.ReadFile(coreConf)
assert.NoError(t, err)
isBucketPathSet := strings.Contains(string(result), "BUCKET_DIR_PATH")
assert.False(t, isBucketPathSet)
}
// Paul: Or whatever you need to do to launch horizon here.
func launchHorizon(t *testing.T, config map[string]string) (func()) {
// Paul: Should this be a temp file? Actually, why does this need to be a file?
cleanupConfig := createCaptiveCoreConfig("./captive-core.toml", CAPTIVE_CORE_CONFIG_STATE_TOML)
test := NewParameterTest(t, config)
defer os.RemoveAll(storagePath)
err := test.StartHorizon()
assert.NoError(t, err)
// FIXME: IntegrationTest needs a big refactor so we can properly wait for
// Captive Core. This needs a lot more thought so this workaround is
// an unfortunate temporary necessity...
time.Sleep(10 * time.Second)
// Paul: Do we need to kill horizon when the test finishes?
return cleanupConfig
}
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think it looks this way because there aren't enough example tests yet, but the intent of the framework here is essentially to associate certain "validation functions" with each parameter. You grouped the parameters together into defaultCaptiveCoreConfig
, but in fact this is supposed to be the unique part. Otherwise, we aren't actually testing the parameter combinations!
However, I do think there's merit to having this default, and then instead modifying only what we need within the test. I'll tinker with it and push my ideas.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Got it, ok. In that case, maybe we should use t.Run("some name", ...)
to label them instead of the matrix function?
Basically, this bit:
runParameterMatrix(test, []ValidatorFunc{
func() { validateCaptiveCoreDiskState(test, STORAGE_PATH) },
func() { validateNoBucketDirPath(test, STORAGE_PATH) },
})
Obscures that these are the important parts, and are actually two separate tests.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Cool. Looks better at least. As a nit, I'd probably inline validateNoBucketDirPath
, and the other one (since they're only used in one place). But it's good enough for now.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fair, but again the idea is that these would be reused in new tests that also wanted to confirm the state of e.g. --captive-core-storage-path
.
Co-authored-by: Paul Bellamy <paul@paulbellamy.com>
- renames, global check - wait for horizon properly - drop extra check - test env-var restoration
Co-authored-by: Paul Bellamy <paul@paulbellamy.com>
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Definitely more refactoring we could do to make testing this simpler, but much better than we had, and more than good enough for now. 💯
PR Checklist
PR Structure
otherwise).
services/friendbot
, orall
ordoc
if the changes are broad or impact manypackages.
Thoroughness
.md
files, etc... affected by this change). Take a look in the
docs
folder for a given service,like this one.
Release planning
needed with deprecations, added features, breaking changes, and DB schema changes.
semver, or if it's mainly a patch change. The PR is targeted at the next
release branch if it's not a patch change.
What
This introduces the
IntegrationTest.Config.HorizonParameters
field, which allows you to customize the parameters passed to Horizon, and refactors the tests to use these accordingly. It also introducesFatalTestSuite
, which is a way to run integration tests and see if they should fail.Note: This is mostly intended a design proposal and an opportunity to get feedback before I dive into actual testing of the combinatorial explosion of parameters we have. There are a few tests and validators included to demonstrate how said tests would look.
Why
In order to facilitate configuration testing (see #3193), we need to be able to run Horizon with arbitrary combinations of parameters.
Known limitations