Testing, CI, CD
Testing, CI, CD
There are a number of ways that I could try and find an HTML element.
But I would like to find an HTML element by its ID.
And I know that the Plus button-- the Increase button--
has an ID of increase, for example.
And so if I find element by ID, let me find the element whose ID is increase.
And, all right.
It seems here, that I've gotten some web element object back from my web driver.
And I'll go ahead and save that inside of a variable called increase.
So what I now have is a variable called increase
that represents the Increase button that my WebDriver has found on the web page.
It's effectively the same thing as you, the human,
going through the page looking for the Increase button.
The WebDriver is doing the same thing, except instead
of looking for the button based on what it looks like,
it looks for the button based on its ID.
And so this, again, another reason why it's
helpful to give your HTML elements IDs.
In case you ever need to be able to find that element,
it's very useful to be able to reference that element by its name.
But now that I have a button, I can simulate user interaction
with that button.
I can say something like increase.click to say
I would like to take the Increase button and simulate a user clicking
on that button in order to see whatever it is that the user would get back
when they click on that button.
So increase.click, I press Return.
And what you'll notice happens is that the number increases-- increases
from 0 to 1.
It's as if I, the user, had actually clicked on the Plus button,
except all I did was say increase.click to say, go ahead and press
the Increase button and let the browser do
what it would normally do in response.
And what it would do in response is get that JavaScript event
handler, that onclick handler, and run that callback
function that increases the value of counter and updates the h1.
So I can say increase.click to simulate increasing the value of that variable.
But this is just a function call, which means
I can include it in any other Python constructs that I want,
that if I want to repeat something like 25 times, for example,
and press the button 25 times, I can say for i in range 25,
go ahead and click the Increase button.
And now, very quickly, 25 times, it's going to click the Increase button.
And I'm going to see the result of all of that interaction.
So I can simulate user interaction just by using the Python interpreter.
Likewise, if instead of increasing, I wanted to decrease,
well, then, I'm going to do the same thing.
I'm going to say decrease equals driver.find_element_by_id.
Let me get the decrease element, the element whose ID is decrease.
And now say, decrease.click.
And that will simulate me pressing the Decrease button.
Press it again.
And the more I press it, every time, it's just going to decrease by 1.
And if I want to decrease it all the way back to 0,
well, then, I'll just do it 20 times.
For i in range 20, go ahead and decrease.click.
And that's going to go ahead and reduce the count all the way back to 0
by simulating the user pressing a button 20 times.
And what you'll notice is that it happened remarkably fast.
Like I can simulate 100 presses of the Increase button
by saying for i in range 100, increase.click.
And very quickly, you'll see that number 100 times go ahead
and go up to 100 faster than a human could ever have clicked
that Plus button over and over again.
And so these tests cannot only be automated,
but they can be much faster than any human could ever be in order to test
this behavior.
So how then can we incorporate this idea into actual tests
that we write, into like a unit testing framework that
allows me to define all of these various different functions
that test different parts of my web application's behavior?
Well, to do that, let's go ahead and take another look at tests.py.
Inside of tests.py, here, again, is that file_uri function,
where that function has the sole purpose of taking a file and getting its URI,
and we need the URI to be able to open it up.
Then we go ahead and get the Chrome WebDriver,
which is going to be what's going to allow us to run
and simulate interaction with Chrome.
And in order to get Chrome's WebDriver, you
do have to get ChromeDriver separately.
It is separate from Google Chrome itself.
But Google does make it available.
And other web browsers make equivalent web drivers available
as well if you'd like to test how things would work in other browsers
because different browsers might behave differently.
And it might be useful to be able to test to make sure that not only does
everything work in Google Chrome, but it's also
going to work in other browsers that you might expect
users to be working with as well.
Here, then, I've defined a class that, again, inherits from unittest.TestCase
that is going to define all of the tests that I would like to run on this web
page, that here I have a function called test_title that's going to go ahead
and first get counter.html.
It's going to open up that page.
And then just assertEqual, let's make sure the title of the page
is actually Counter.
That's what I would expect it to be.
So I can write a test in order to test for that case as well.
Here, I test the Increase button by finding the element whose
ID is increase and clicking on that button
to simulate a user pressing the Plus button in order
to increase the value of the counter.
And then, what do I want to check?
Well, I want to check that when you find element by tag name,
h1, and so find_element_by_tag_name, similar to find_element_by_id,
except instead of finding something by its ID,
it's going to look at what's the tag.
And there is only one element that is in h1.
And so here I'm saying, go ahead and get me the H1 element
and access its text property, meaning whatever
it is that is contained inside of those two H1 tags,
I would expect that to be the number 1.
And I would assert that this is equal to 1.
And likewise, I can do the same thing for the Decrease button--
finding the element whose ID is decrease, clicking on that button,
and then asserting equal, find me the H1 element
and make sure that the contents of it are
equal to the number negative 1, for instance.
And this final test, just test things multiple times, that three times I'm
going to press the Increase button and make sure
that after I press the Increase button three times, when I check the h1,
check what's inside of its text, that the answer should, in fact, be 3.
So now I should be able to go ahead and test
this code by running python tests.py.
And what that is going to do is it's going to open up a web browser.
And what you're going to see, very quickly flashed across my screen,
were all of those tests.
We tested the increase by 1.
We tested decreased by 1.
And then we tested like increase 3 times after we had checked
to make sure the title was correct.
And then we can see here is the output.
We ran four tests in this amount of time,
and everything turned out to be OK.
None of the tests failed.
But if one of the tests had failed, well, then,
we would see a different output.
So let's imagine, for example, that I had had a bug in my decrease function,
for example, where the decrease function wasn't actually working.
What would that bug look like?
Maybe I forgot to say counter minus minus.
Or maybe, perhaps more likely, what might have happened is I wanted to--
I already had written the increase function,
and I decided to very quickly add the decrease function,
and I thought I just--
like Copy/Paste, like copy the increase event handler.
The decrease event handler is basically the same thing
except I need to query for decrease instead.
And maybe I just did that and forgot to change plus
plus to minus minus, a bug that might happen
if you're not too careful about how you copy and paste code from one place
to another.
Now when I run these tests, python tests.py, we'll see the simulation.
A whole bunch get simulated.
And when I go back and check the output of my tests,
see what actually happened, I see that we do
seem to have an assertion error here.
The assertion fail was on the test decrease function.
And it happened when I tried to assert that what was inside of the H1 element
was negative 1 because 1 is not equal to negative 1.
So this is the value of this assertion error as well.
And this is helpful, an advantage over just assert.
Assert just tells you there is an assertion error.
But here, in unittest, we actually get to see
if I asserted that two things are equal, it tells me what both of those things
are.
It tells me the actual output of h1's text.
It was 1.
But what I expected it to be was negative 1.
So it tells me exactly what the discrepancy is.
I know that for some reason, it was 1 instead of negative 1.
And that can be a clue to me, a hint to me,
as to how I can go about trying to solve this problem.
And I can solve the problem by going into my decrease event handler,
seeing that, all right, this was increasing the counter
instead of decreasing it.
Change plus plus to minus minus, and now rerun my tests
and see all of the test simulated inside of my Chrome Driver.
And we ran four tests.
And this time, everything was OK.
So all of my tests appear to have passed this time.
So those, then, are some possibilities for being able to test our code,
especially taking advantage of unittest, this library that we
can use in Python in order to make various types of assertions
about what we would like to be true or false about our code.
And unittest contains a number of helpful methods
for being able to perform these sorts of assertions.
Some of them are here.
So we can say things like I would like to assert that two things are
equal to each other, which we've seen.
There's a counterpart to that, assertNotEqual for making sure the two
things are not equal to one another.
assertTrue and False, we've seen as well.
There are others as well though, things like assertIn or assertNotIn,
if I would like to assert, for example, that some element is in some list,
for example, or that some element is not in some list.
There are other assert methods as well that we can use in order
to verify that a part of our program or a part of our web application
does, in fact, behave the way we would want to behave.
And we can integrate this type of idea into a number
of different types of testing.
We saw integrating it into Django itself, using Django as unit testing
in order to verify that our database works the way we expected it to,
and that our views works the way that we expected them to and provided
the right context back to the user after the user makes a request to our web
application.
And there are also applications of unit testing,
whether using the framework or not, to browser-based testing,
when I want to test inside of the user's web browser.
Does it actually work when a user clicks on this button,
that the JavaScript behaves the way that I would expect it to.
And I don't need to especially use JavaScript in order to do those tests.
I didn't write those tests using Python, using unittest,
to be able to say, click on the button that has this ID
and verify that the result that we get back is what we would expect it to be.
So that then was testing.
And now we'll go ahead and take a look at CI/CD--
Continuous Integration and Continuous Delivery
which refer to two best practices in the software development world
that has to do with how it is that code is written,
especially by groups or teams of people; how it all works together,
and how that code is eventually delivered and deployed
to users who are using those applications.
So CI, which refers to Continuous Integration,
involves frequent merges to a main branch of some repository,
like a Git repository, and then automatically running
unit tests when that code is pushed.
So what does that generally mean?
Well, in the past, you might imagine that if multiple people are working
on some project at the same time and multiple people are each
working on different features or different parts of that project,
then after everyone's done working on those features
and we're ready to ship some new version of a web application
or ship some new version of a software product, well, then,
everyone's going to have to take all these various different features
and combined them all together at the end
and figure out how to then try and deliver that program to users.
And this has a tendency to cause problems,
especially if people have been working on different big changes
all simultaneously.
They might not all be compatible with one another.
There might be conflicts between the various different changes
that have been made.
So waiting until everyone is done working on a feature
to merge them all back together and then deliver it
is not necessarily the best practice, which
is why increasingly, many more teams are beginning
to adopt a system of continuous integration,
that there is one repository somewhere online
that's keeping the official version of the code.
Everyone works on their own version of the code, maybe on their own branch,
for example.
But very frequently, all of these changes
are merged back together into the same branch
to make sure that these incremental changes can be happening such
that it's less likely that there's two really divergent paths that the program
has gone under, and as a result, it's much more
difficult to merge those two paths back together.
In addition to frequently merging to its own main branch,
another key idea of continuous integration
is this idea of automated unit testing, where unit testing, again,
refers to this idea of on our program, we
run a big series of tests that verify each little part of our program
to make sure that the web application behaves the way it is supposed to.
And unit tests generally refer to testing particular small components
of our program, making sure that each component works as expected.
There are also bigger scale tests-- tests like integration tests that
make sure that the entire pathway from user request
and response, that everything along a certain pipeline works as well.
But there are various different types of testing.
And the important thing is making sure that anytime
some new change is merged into the main branch or someone
wants to merge their changes into the main branch,
that these tests are run to make sure that nobody ever
makes a change to one part of a program that
breaks some other part of the program.
And in a large enough code base, it's going
to be impossible for any one person to know
exactly what the effect of one particular change
is going to be on every other part of the program.
There are going to be unforeseen consequences that the one
programmer may or may not know about.
And so the advantage of unit testing, assuming
they're comprehensive and cover all of these various different components
of the program, is that any time someone makes a change
and attempts to merge that change into the main branch
according to the practice of continuous integration, the fact
that it doesn't pass a test, we'll know about that immediately.
And as a result, that programmer can go back
and try to fix it as opposed to waiting until everything is done,
merging everything together, and then running
the tests, realizing something doesn't work,
and then being unsure of where to begin.
We don't know where the bug is, which change happened to cause the bug.
If everything is merged more incrementally,
it's easier to spot those bugs, assuming there's
good coverage of tests to make sure that we're
accounting for these various different possibilities.
So continuous integration refers to that idea--
frequently and more incrementally updating the main branch
and making sure that the tests are, in fact, passing.
And it's closely tied to a related idea of continuous delivery, which
is about the process of how it is that the software is actually
released to users, how the web application actually gets deployed.
And there are a couple of models you might
go about thinking with regards to how it is that some program or web
application gets deployed.
You might imagine that the release cycle might be quite long,
and the people spend months working on various different features
on some software development team.
And after they're happy with all the new changes,
they've released some new version of the web application.
But especially, with web applications that
are undergoing constant change, that have
lots of users, that are moving very quickly, one thing that's quite popular
is this notion of continuous delivery, which refers
to having shorter release schedules.
Instead of immediately releasing something
at the end of some long cycle, you can in shorter cycles
make releases every day, every week, or so
in order to say that let's just go ahead and incrementally make those changes.
Whatever new changes happen to have merged to the main branch,
let's go ahead and release those as opposed to waiting much longer in order
to perform those releases.
And that, again, lends itself to certain benefits,
the benefit of being able to just incrementally make
changes, such as something goes wrong, you
know more immediately what went wrong as opposed
to making a lot of changes at once, where if something goes wrong,
it's not necessarily clear what went wrong.
And it also allows new features to get out to users much more quickly.
So especially in a competitive market where many different web applications
are competing with one another, being able to take a new feature
and release it very quickly can be quite helpful.
So continuous delivery is all about that idea of short release cycles.
Rather than wait a long time for a new version to be released,
release versions incrementally as new features begin to come in.
It's closely related to the idea of Continuous Deployment, which
CD will sometimes also represent.
Continuous deployment is similar in spirit to continuous delivery.
But the deployments happen automatically.
So rather than a human having to say, all right,
we've made a couple of changes.
Let's go ahead and deploy those changes.
In continuous deployment, any time these changes are made,
the pipeline of deploying the application to users
will automatically take place as well, just removing one thing for humans
to have to think about and allowing for these deployments
to happen even more quickly as well.
So the question then is, what tools can allow
us to make continuous integration and continuous delivery
a little bit easier?
What techniques can we use in order to do so?
And there are a number of different continuous integration tools.
But one of them produced by GitHub more recently is known as GitHub Actions.
And what GitHub Actions allows us to do is to create these workflows where
we can say that anytime, for example, someone pushes to a Git repository,
I would like for certain steps to take place,
certain steps that might be checking to make sure that the code is styled well.
That if a company has some style guide that it expects all of its programmers
to adhere to when working on a particular product,
you could have a GitHub Action such that anytime someone pushes to a repository,
you have an action that automatically checks
that code against the style guide to make sure
that it is well-styled, well-commented, documented, and so forth.
You might also, for instance, have a GitHub action that
tests our code to make sure that anytime anyone pushes code to a GitHub
repository, we automatically run whatever
tests we would like to run on that particular code base.
And GitHub Actions can allow us to do that as well by defining some workflow
to be able to do so.
And so that's what we'll take a look at in just a moment,
using GitHub Actions to automate the process of running tests so that
the human-- though it would be a good thing for the programmer when
they're done writing their code to test their code and make sure it works--
we can enforce that by making sure that every time anyone pushes to a GitHub
repository, we'll automatically run some GitHub
action that is going to take care of the process of running tests
on that program.
And we'll know immediately as via an email
that GitHub might send to you to say that this particular test failed.
And you'll know every time you push to that repository.
So how do these workflows get structured?
What is the syntax of them?
Well they use a particular type of syntax known as YAML,
which is some language, a configuration language,
that can be used in order to describe--
often described for configuration of various different tools and software.
GitHub Actions happens to use it.
Other technologies use it as well.
And YAML is a file format that structures its data sort of like this,
in terms of key value pairs, much in the same way
that a JSON object or a Python dictionary might,
where we'll have the name of a key followed
by a colon followed by its value--
name of a key, followed by a colon, followed by a value.
And the value doesn't necessarily need to be just a single value.
It could be a sequence of values, like a list of values, for example.
And those are generated this way, by like a hyphen indicating
a list-- item 1, item 2, item 3.
And in addition to just having single values
and lists of items, these ideas--
these lists, these sequences, these values--
can be nested within one another, that you
might have one key that leads to another set of keys
that are associated with values that leads to other sets
of keys associated with values as well.
Much in the same way, that a JSON object,
like a representation of keys and values,
can also have nested JSON objects within a JSON object.
Likewise, too, we can have nested key value pairs
as the value for a particular key too.
So we'll take a look at an example of what that actually
looks like in the context of creating some GitHub workflow that
will run some get GitHub Actions.
So what will that look like?
Let's go back into airline0, where here, I've defined inside of a .github
directory a directory called workflows, inside of which I have a ci.yml file.
It can be any name .yml.
.yml or .yaml are the conventional file extensions for a YAML file.
And here, I'll open up ci.yml.
And this now is the configuration for how this workflow ought to behave.
I give the workflow a name.
It's called Testing because what I want the workflow to do
is test my airline application.
Then I specify an on key to mean when should this workflow run.
And here, I have said on push, meaning anytime
someone pushes their code to GitHub, we would like to run this workflow.
Every workflow consists of some jobs.
And so what are the jobs?
What tasks should happen anytime that I try and push to this repository?
Well, I've defined a job called test_project.
And this is a name that I chose for myself.
You can choose any name for a job that you would like.
And now I need to specify two things for what happens on a job.
One thing I need to specify is what sort of machine is it going to run on?
That GitHub has its own virtual machines, otherwise known as VMs,
and I would like to run this job on one of those virtual machines.
And there are virtual machines for various different operating systems.
Here I'm just saying, go ahead, and run on the latest version of Ubuntu,
which is a later version of Linux that I would like for this test to run on.
And then for the job, I specify what steps
should happen where I can now specify what
actions should happen when someone tries test a project when I try and run
this job.
And here I'm using a particular GitHub action.
And this is a GitHub action written by GitHub called actions/checkout.
And what this is going to do is it's going
to check out my code in the Git repository
and allow me to run programs that operate on that code.
And you can write your own GitHub actions if you would like.
But here, all we really need to do is go ahead and check out the code,
as by looking at what's on the branch that I just pushed to.
And then I'm going to run Django unit tests.
This is just a description for me to know what's
going on in this particular step.
And here is what I would like to run.
I'm going to first go ahead and install Django
because I'm going to need to install Django
to be able to run all of these tests.
But after-- and if there are other requirements,
I might need to install those requirements as well.
But the airline program is fairly simple.
All we really need in order to run the tests is just Django.
So I'll go ahead and install Django.
And then I'll run python3 manage.py test.
I would like to test--
run all of the tests.
And the way I can do that is just by providing this manage.py command
to say that I would like to run all of the tests
on this particular application.
So this configuration file altogether now
is going to specify a particular workflow, the workflow that
says that every time I push to the GitHub repository,
what I would like to happen is I would like to check out
my code inside of the Git repository.
So on some Ubuntu VM, GitHub is going to check out my code,
and it's going to run these commands.
It's going to install Django.
And then it's going to test my code.
And it will then give back to me what the response is after I do that.
So let's go ahead and test this.
And in particular, let's run it on a program where
the tests are going to fail.
So I might say, for example, let's go into flights and models.py.
And let's go to my is_valid_flight function from before
and change it back to that version that didn't work.
That before it was something and something.
I'll change it to something or something.
That as long as the origin is not the destination or the duration
is greater than 0, we'll count that as valid.
But we know that that's wrong.
That should not work.
So here's what I'll do.
I'll go ahead and first say git status, see, all right, what's changed?
And it seems that, all right, I've changed--
I've modified models.py, which makes sense.
I'll go ahead and git add.
I'll add dot.
We'll just add all of the files that I might have modified.
I'll commit my changes.
Say go ahead and use wrong valid flight function.
That's what I'm going to do.
And now I'm going to push my code to GitHub.
I added it.
I committed it.
I pushed it.
That now then pushes my code to GitHub into a repository called airline
that I already have.
And now, if I go ahead and go to GitHub, and I go to my airline
repository, what you'll notice is that we've mostly
been dealing with this Code tab.
But GitHub gives us other tabs as well that
are quite useful as you begin to think about working
on a project in larger team.
So in addition to looking at the code, we have issues.
Issues are ways for people to just report that something is not
quite right, or there is a feature request
that we have for this particular code base.
So the issues might maintain a list of all of the pending action
items for a particular repository, things that we still need to deal with.
And once those issues are dealt with, the issues can be closed.
So I have no issues here as well.
Pull requests are people that are trying to merge some part of the code
from one branch into another branch.
So you might imagine on a larger project,
you don't want everyone merging things into master all at the same time.
You might have people working on their own separate branches.
And then when they feel confident and happy with their code,
then they can propose a pull request to merge their code into the master
branch.
And that allows for various other features,
like the ability for someone to offer a code review--
to be able to review the code, write comments, and propose suggestions
for what changes should be made to a particular part of the code
before it gets merged into the master branch.
And that's another common practice with regards
to working on a GitHub repository or any other larger project
that you're controlling using source control is this idea of code reviews,
that oftentimes, you don't want just one person making the changes
without anyone's eyes on that code.
But you want a second pair of eyes to be able to look things over, make
sure the code is correct, make sure it's efficient,
make sure it's in line with the practices
that the application is using.
And so pull requests can be quite helpful for that.
And then this fourth tab over here represents GitHub Actions.
These are the various different actions or workflows
that I might want to run on this particular repository.
And what we'll see here is that if I go to the Actions tab now, what I'll see
is here is my most recent testing actions.
So anytime I push, I get a new testing action.
This one was from 29 seconds ago.
I'll go ahead and click on it and see what's within it.
All right.
Here was the job that I ran, test_project.
I see that on the left-hand side.
You'll notice this big red X in the left-hand side of this workflow.
Means something went wrong.
So I'd like to know what it is that went wrong.
I'll go ahead and click on test_project.
And here within it, these are all of the steps, the things that happened when
we actually ran this particular job.
First the job sets up.
Then the checkout action goes ahead and checks out my code
because we need access to my code to be able to run it.
Here was the step I defined--
run Django unit tests, which was going to install Django and run those tests.
It has an X next to it, indicating something went wrong.
And I see down below, annotations, 1 failure.
So all over the place, GitHub's trying to tell me that something went wrong.
It failed two minutes ago here.
I'll go ahead and open this up.
And what I'll see is the first thing that happened is we installed Django.
And that seems to have worked OK.
But down below, what you'll see is the output of running these unit tests,
that we see FAILED (failures-2).
And now I can see, here are the unit tests that failed.
We failed the invalid flight destination test.
We failed the invalid flight duration test.
And as before, I can see in GitHub's user interface
what those assertion errors are.
I can see a true is not false.
True is not false, those were the problems
that happened when I tried to run this particular test suite.
And now others who are also working on this repository
can see as well what the results of these tests
are and can offer suggestions, can offer ways
that I might be able to fix the code in order to deal with that problem.
But now I know that this particular test failed.
And if I go back to the main code page for this GitHub repository,
I'll see that next to this commit, there is a little x symbol.
And that little x symbol next to the commit
just tells me that the most recent time I tried to commit,
something went wrong.
They ran the workflow, and there was an error.
And so I'll immediately see for this commit--
and I can go back and look at the history of commits
and see which ones were OK and which ones had a tendency
to cause some sort of problem.
So this one, it appears caused a problem.
And we know why.
It caused a problem because of this condition, something or something else.
So I can fix it.
I'll change the or to an and.
I'll go ahead and git add dot.
git commit.
Say I will fix valid flight check.
If I do git status just to check out what's going on right now,
I'm ahead of the master branch by 1 commit.
That's exactly what I would expect.
And now I'll go ahead and push my code to GitHub by running git push,
saying, all right, let's push this update.
And now, hopefully, we're going to pass the workflow now.
Now I go back to the repository.
I refresh the page.
Here's my latest commit-- fix valid flight check.
You notice here, there's an orange dot instead of the red x as before.
This dot just means the tests are currently pending.
The workflow is in progress because it takes some time for GitHub
to be able to start up the VM, to be able to initialize the job,
to check out my code, to run all those tests.
It does take some time.
But if I go back to the Actions tab, I'll see that, all right.
This time, for testing, we get a green check mark.
Everything seems to be OK.
I go to test_project just to see it.
And now I notice the green check mark next to Run Django unit tests
means that the unit tests have passed as well.
If I open those up, now I see at the bottom
the same output that I saw before when I was running those unit
tests on my own machine.
We ran 10 tests, and everything was OK.
And that tells me that these tests have passed.
So GitHub Actions have the ability to allow for certain jobs to happen,
certain work to happen anytime you push code, anytime you submit a pull request
or on various different actions that might happen on a GitHub repository.
And they're very helpful for being able to implement
this idea of continuous integration because it means you can make sure
that when you're merging code from some developer's branch into the main branch
that everyone's merging their code into, you
can verify that those tests can pass.
And you can add rules to say that you don't
want to allow anyone to merge code into the branch if the tests don't pass,
to guarantee that any code that does get merged
is going to pass all of those tests as well.
And so that can definitely help the development cycle,
make it easier to ensure that changes can be made quickly.
But as we make those changes quickly, we're
not going to lose accuracy and validity within our code,
that we can make sure that our code still
passes those tests by automating the process of running
those tests altogether.
So other than continuous integration then,
we now talk about this idea of continuous delivery,
these short application cycles where we would
like to very quickly be able to deploy our application onto some sort of web
server.
And when we're deploying applications to a web server,
there are things that we need to think about.
We need to think about getting our program that
was running fine on our computer working on a web server as well.
And this can just be fraught with headaches
and all sorts of configuration problems because you
might imagine that the computer that you are using
is not necessarily going to be the same as the computer that on the cloud,
the computer in the server where your web application is actually running.
It might be running a different operating system.
It might have a different version of Python installed.
If you have certain packages working on your own computer,
those same packages might not be installed on the server.
So we run into all sorts of various different configuration problems
where you can be developing, deploy your code,
and realize that it doesn't work on the server because
of some sort of difference between what's happening on your computer
and what's happening on the server.
And this becomes even more problematic if you're working on a larger team, you
and multiple other people working on a software project,
but you each have different versions of various different packages or libraries
installed, and those different versions have different features
and might not all work and cooperate with one another.
And so we need some way in order to be able to deploy applications
efficiently and effectively to be able to standardize on just one
version of the environment, one version of all these packages,
to make sure that every developer is working
on the project in the same environment.
And once we deploy the application, it's going
to be working in the same environment as well.
And the solution to this comes in a number of possible options.
But one option is to take advantage of a tool
like Docker, which is some sort of containerization software.
And by containerization software, what we're talking about
is the idea that when we're running an application, instead of just running it
on your computer, we're going to run it inside of a container on your computer.
And each container is going to contain its own configuration.
It's going to have certain packages installed.
It's going to have certain versions of certain pieces of software.
It's going to be configured in exactly the same way.
And by leveraging a tool like Docker, you
can make sure that so long as you provide the right instructions for how
to start up and set up these containers, then
if you are working on the application and someone you're working with,
some colleague that's also working on the same project,
so long as you're using the same instructions for how to set up a Docker
container, you're going to be working in the identical environments,
that if a package is installed on your computer, in your container,
it's going to be installed in your colleague's container as well.
And the advantage of this too works with this idea of continuous delivery.
When you want to deliver and deploy your application to the internet,
you can run your application inside of that exact same container, set up
using the exact same set of instructions,
so that you don't have to worry about the nightmare headaches of trying
to make sure that all the right packages and all the right versions
are, in fact, installed on the server.
Docker might remind you of the idea of a virtual machine or a VM
if you're familiar with that concept.
GitHub uses VMs, for instance, when running its GitHub Actions.
They are, in fact, different.
A VM is effectively running an entire virtual computer
with its own virtual operating system and libraries and application
running on top of that all inside of your own computer.
So a virtual machine ends up taking up a lot of memory, taking up
a lot of space.
Docker containers, meanwhile, are a bit lighter-weight.
They don't have their own operating system.
They're all running still on top of the host operating system.
But there is this Docker layer in-between
that keeps track of all of these various different containers
and keeps track of for each container such
that every container can have its own separate set of libraries,
separate set of binaries, and an application running on top of that.
So the advantage then of containerization
is that these containers are lighter-weight than having
an entire virtual machine.
But they can still keep their own environment consistent such
that you can feel confident that if the application is working in a Docker
container, you can have that Docker container running on your computer,
on someone else's computer, on the server
to guarantee that the application is going to work the way that you would
actually expect it to.
And so how exactly do we configure these various different Docker containers?
Well, in order to do so, we're going to write what's called a Docker file.
So to do this, I'll go ahead and go into airline1.
And I'll open up this Docker file.
And the Docker file describes the instructions
for creating a Docker image where the Docker image represents
all of the libraries and other installed items
that we might want to have inside of the container.
And based on that image, we're able to create
a whole bunch of different containers that
are all based on that same image, where each container has its own files
and can run the web application inside of it.
So this Docker file, for example, describes
how I might create a container that is going to run my Django web application.
So first, I say FROM python:3.
This happens to be another Docker image on which I'm
going to base these instructions, that this
is going to be a Docker image that already contains instructions
for installing Python 3, installing other related packages that
might be helpful.
Oftentimes, when you're writing a Docker file,
you'll base it on some existing Docker file that already exists.
So here I'm saying go ahead and use Python 3.
And now what do I want to do in order to set up this container?
Well, I want to copy anything in dot, in my current directory,
into the container.
And I have to decide, where in the container am I going to store it?
Well, there-- I could choose to store it anywhere.
But I'll just store it in /usr/src/app, just some particular path that will
take me to a directory where I am going to store the application.
But you could choose something else entirely.
So I copy all of the current files in my current directory.
So that will include things like my requirements file, my manage.py file,
my applications files, all my settings files.
Everything inside of the directory, I would like to copy into the container.
Then I'm saying WORKDIR, meaning change my working directory, effectively
the same thing as something like CD on your terminal
to move into some directory.
I would like to set my working directory equal to that same application
directory, the application directory inside of the container that
now contains all of the files from my application
because I copied all of those files into the container.
Now once I'm inside of this directory, I need to install all of my requirements.
So assuming I've put all my requirements like Django and any other packages
that I need inside of a file called requirements.txt,
I can just run the command, pip install requirements.txt.
And then, finally, inside the Docker file, I specify a command.
And this is the command that should run when I start up the container.
Everything else is going to happen initially when we're just
setting up this Docker image.
But when I start up the container and actually want
to run my web application, here is the command that should run.
And I provide it-- effectively it's like a Python list where
each word in the command is separated by a comma, where here I'm
saying the command that I would like to run, when you start up this container
is python, manage.py, runserver.
And here I'm just specifying on what address and what port
I would like it to run.
And I'm running it on port 8000, for example.
But I could choose another port that I would like to run instead.
So what's going to happen then is that when I start up this Docker container,
it's going to, if it needs to, go through these instructions
and make sure that it sets up the container according
to these instructions, make sure that we've
installed all of the necessary requirements,
make sure that we're using Python 3.
And anyone using the same Docker file can
generate a container that has all the same configuration on it.
So we don't have to worry about configuration differences between me
and someone else who might not have the exact same computer setup that I do.
And the nice thing about this is that it can run on Mac and Windows and Linux.
So even people running on different operating systems
can still have containers that all have the same configuration,
that all work in the same way just to speed up that process.
Now so far, when we've been building Django applications,
we've been using a SQLite database.
SQLite database just being a file that is stored inside of our application.
And this file-based database allows us to create tables, insert rows into it,
delete rows from it.
In most production environments, in most real web applications
that are working with many, many users, SQLite
is not actually the database that is used.
It doesn't scale nearly as well when there are many users all trying
to access it concurrently.
Oftentimes, in those sorts of situations,
you want your database hosted elsewhere on some separate server
to be able to handle its own incoming requests and connections.
And we talked about a couple of possible databases
we could use instead of SQLite, things like MySQL, things like Postgres,
or various different SQL-based databases.
So imagine now I want to deploy my application.
But instead of using SQLite, I would like to use Postgres, for example,
as the database server that I would like to run.
Well, that would seem to be pretty complicated for me to test on my own
because now in addition to running my web application in one server,
effectively, I also need another server that's running Postgres, for example,
such that I can communicate with that Postgres database instead.
And that's going to be even harder for other people
to be able to work on as well.
Potentially, it might be difficult to get the server to work in that way too.
But the nice thing about Docker is that I
can run each of these processes in a different container effectively.
I can have one container that's running my web application using this Docker
file right here.
And I can have another container that's just going to run Postgres.
And as long as other people also have access
to that same container for running Postgres,
they can be working in an identical environment to the one
that I am working in as well.
And so there's also a feature of Docker known as Docker Compose.
And what Docker Compose lets us do is allow
us to compose multiple different services together,
that I would like to run my web application in one container,
and I would like to run a Postgres database in another container.
But I would like for those containers to be able to talk to each other,
to be able to work together whenever I start up the application.
So if I'd like to do that, in order to run this application on my computer
and have both the web application and Postgres installed,
I can create a Docker Compose file which looks like this.
Here I'm specifying using version 3 of Docker Compose.
Here I specify, again, using a YAML file.
Much as in my GitHub workflows were formatted in YAML just
as a configuration file, docker-compose.yml is a YAML file that
describes all of the various different services that I want to be part
of my application, where each service is going to be its own container that
could be based on a different Docker image.
Here I'm saying that I have two services, one called db for database,
one called web for my web application.
The database is going to be based on the Postgres Docker image, image
that Postgres wrote that I don't have to worry about.
Someone else has written the Docker file for how
to start up a Postgres container.
Here, though, for the web application, that's
going to be built based on the Docker file in my current directory,
the Docker file that I have written.
And then down below, I've just specified that my current directory
should correspond to the app directory.
And then I've specified when I'm running this on my own computer,
I would like port 8000 on the container to correspond
to port 8000 on my own computer just so that I
can access port 8000 in my browser and access port 8000 inside the container.
It just lets my computer actually talk to the container
so I can open up the web application in my web browser, for example,
and actually see the results of all of this.
So here, then, I've created two services, a database and web.
So now let's actually try starting up these containers.
I'm going to first go into my airline1 directory.
And I'm going to say docker-compose up to mean go ahead
and start up these services.
I'll press Return.
And what you'll see is we're going ahead and starting up two services.
I'm starting up the database service.
And I'm starting up the web service.
And now as a result of all of this, I've started up the application.
And I started it on port 8000.
So if I go to 0.0.0.0 slash 8000 or colon 8000 slash flights,
that's going to take me to the Flights page.
And now this is running, not just on my own computer,
but inside of a Docker container.
Now, of course, right now, there are no flights inside
of this page because I haven't actually added anything to the database yet.
So I could do that if I wanted to.
But how do I do that?
Well, I needed to go into slash admin to say, like, let me log in
and go ahead and create some sample flights.
But I don't have a log in yet because I need to create a superuser account.
And I can't just like inside of my airline1 directory
say, python manage.py createsuperuser the way
that I used to because this is running in my terminal on my computer.
Whereas, what I really want to do is go into the Docker container
and run this command there, inside of the container.
So how can I do that?
Well, there are various different Docker commands that I can use.
docker ps will show me all of the Docker containers that are currently running.
So I'll go ahead and shrink this down a little bit.
I see two rows, one for each container, one for my Postgres container
that's running the database, one for just my web application
that's running as well.
Each one has a container ID.
So I want to go into my web application container
in order to run some commands inside of that container.
So I'm going to copy its container ID and say, docker exec--
meaning go ahead and execute a command on the container-- dash
it will make this interactive.
Here's the container ID that I would like to execute a command on.
And the command I want to execute is bash, passing the dash l flag,
but bash to say, I want to run a bash prompt.
I want to be able to interact with a shell
so that I can run commands inside of this container.
So I press Return.
And now what you'll notice is that I am inside
of my container in the user source app directory,
that directory that contained all of the information about this web application.
I type ls.
And what I'll see is here, all the files inside of this container now.
And now I can say something like python manage.py createsuperuser.
And now it's going to let me create a superuser.
So I'll create a user inside of my web application called Brian.
I'll give it my email address.
I'll type in a password.
And now we've created a superuser.
And you can run other commands here.
If you wanted to migrate all of your migrations,
I could say python manage.py migrate.
And it turns out I've already done that, so I didn't actually
have to do it again.
But you can run any commands that you can run them on your computer.
But now you can run them inside of the Docker container instead.
I'm going to press Control D just to log out, get out of the container
and get back to my computer.
But now I've created a superuser, so I could
go ahead and sign in to Django's admin.
And now I can begin to manipulate this database, which
is a Postgres database running in a separate container.
But the nice thing about it is that I can start them
both up together just by running something like docker-compose
up, for example.
So Docker can be quite a powerful tool for allowing us to very quickly ensure
that an application is running in the environment
that we expect it to be running, to make sure that all of the right libraries
are installed, make sure that all the right packages are installed as well,
that the configuration between my development environment
and the environment that's running on the server are the same as well.
So those then were just some of the best practices
for how you can go about developing a program now
that we have the tools to do so.
We have a lot of tools for being able to develop these web applications.
But as our programs start to get more complex,
it will be increasingly important to test them, make sure
that each various different component of our web application
behaves the way that it is expected to behave, and then taking advantage,
especially in bigger teams, of CI/CD, Continuous Integration, Continuous
Delivery to make incremental changes, and make
sure each of those incremental changes, in fact, works on the web application.
And then CD, Continuous Delivery, to say that rather than wait and then deploy
everything all at once, let's deploy things incrementally as well.
Let users more quickly get access to the latest features
and more quickly find out if something went wrong.
We can better identify what it is that went wrong
if we've deployed things incrementally rather than waiting
a long time in order to do so as well.
So these are some of the best practices in modern software application
development, not only for web applications but for software
more generally.
Next time, we'll consider other challenges
that might arise as we go about trying to make web applications that
are used by more and more users, in particular,
taking a look at challenges that will arise in terms of scalability
as the programs get bigger and also security of what security
vulnerabilities open themselves up as we begin
to design our web applications using Python and JavaScript.
So more on that next time.
And we'll see you then.