This repository is meant to help you as you are watching and learning through the course.
By default, when you land on this page, the current branch will be master
which will have all of the latest code. As you follow along, make sure to switch to the branch or commit that matches your current chapter.
For example, if you want to see the code used for Chapter 5, but don't want to see the solutions to the challenge(s):
-
Click
Commits
- This should take you to something like https://github.com/ElSnoMan/from-scripting-to-framework/commits/master
-
Click on the Commit you want to view
- In this case, click
Chapter 5 - Customizing WebDriver
- In this case, click
If you want to see the solutions to Chapter 5's challenges:
-
Click
Commits
-
Click on the
Challenge
or(solution)
Commit- In this case, click
Chapter 5 - Challenge
- In this case, click
This will show you the code that I used to solve the challenge, but note that there are usually multiple solutions that are valid.
Can't build or "Driver" does not contain a definition for FindElement
In chapter 5, you will have a Royale.Pages
namespace. dotnet core will raise problems because the namespace shares the same name as the Pages.cs
file and class! PLEASE CHANGE THE NAME OF Pages.cs
and its class to something else like PageWrapper.cs
! This will resolve build issues
Each chapter in this README is an overview with highlights of the chapter. These are helpful since it's easier to keep this up-to-date than videos.
If you get stuck, take a look at the chapter's overview for some extra guidance.
If you are still stuck, feel free to create an issue or ping me on the TAU Slack Channel.
-
Requirements
- .NET Core version 2.2 (latest at time of recording) or greater
- VS Code
-
Make a new project called
scripting-to-framework
and open it in VS Code -
Install Extensions in VS Code
- C# by Microsoft
- PackSharp by Carlos Kidman
-
Open the Command Palette for each of these commands
PackSharp: Create New Project
> selectClass Library
> call the Project "Framework"PackSharp: Create New Project
> selectClass Library
> call the Project "Royale"PackSharp: Create New Project
> selectNUnit 3 Test Project
> call the Project "Royale.Tests"PackSharp: Create New Project
> selectSolution File
> call the Project "StatsRoyale"
NOTE: The Solution (.sln) file will manage the Project (.csproj) files while the Project files handle their own packages and dependencies. As you add things, this is all handled for you! Very cool.
-
Add the Projects to the Solution. Run these commands in the Terminal:
$ dotnet sln add Framework
$ dotnet sln add Royale
$ dotnet sln add Royale.Tests
-
Build the Solution so we know everything is working
$ dotnet build
-
Open the
UnitTest1.cs
file in the Royale.Tests project- C# will "spin up" so you can start coding in C# and get helpful completions, hints, etc.
- VS Code will ask if you want "Required assets to build and debug". Add them by clicking the "Yes" button.
- If you do not get this immediately, try closing VS Code and reopening the project.
- This will add a
.vscode
folder to your solution, but this is required to run and debug the tests.
-
Run the Tests
$ dotnet test
- This will run all the tests, but you only have one right now. It should pass.
NOTE: Our application under test (website) is https://statsroyale.com
-
Change the name of the Test Class and Test Method so they make more sense
- Test Class from
Tests
toCardTests
- File name from
Tests.cs
toCardTests.cs
- Test Method from
Test1()
toIce_Spirit_is_on_Cards_Page()
- Test Class from
-
Install Selenium NuGet (package) with PackSharp
- Open Command Palette > select
PackSharp: Bootstrap Selenium
> add toRoyale.Tests
, our Test Project - If you open the
Royale.Tests.csproj
file, you will see that Selenium packages have been added - You will also see a
_drivers
directory is added at the Workspace root
- Open Command Palette > select
NOTE: This command installs the latest versions of chromedriver and the Selenium packages.
-
Use Selenium in our CardTests.cs file
- Within the CardTests class, add the
IWebDriver driver;
field to the top - Resolve the error by hovering the red line and click on the lightbulb
- The first option will want you to add the
using OpenQA.Selenium;
statement. Select that one - The error will go away and you will see that using statement is added automatically
- The first option will want you to add the
- Within the CardTests class, add the
-
SetUp and TearDown methods
- The [SetUp] method is run before each test. Change the method name from
Setup()
toBeforeEach()
- Add a [TearDown] method which runs after each test. Call this method
AfterEach()
- The [SetUp] method is run before each test. Change the method name from
-
Within AfterEach(), add:
driver.Quit();
- This will close the driver after each test is finished
-
Within BeforeEach(), add:
driver = new ChromeDriver(<path-to-chromedriver>);
- This will open a new Chrome browser for every test
- "Point" the ChromeDriver() to wherever you store your
chromedriver
orchromedriver.exe
.
NOTE: Everyone manages their drivers (like
chromedriver
,geckodriver
, etc.) differently. Use your preferred method.
-
Write the first test. The steps are:
- Go to https://statsroyale.com
- Click on Cards link
- Assert Ice Spirit is displayed
-
For the second test, the steps are:
- Go to https://statsroyale.com
- Click on Cards link
- Click on Ice Spirit card
- Assert the basic headers are correct. These headers are:
- Name ("Ice Spirit")
- Type ("Troop")
- Arena ("Arena 8")
- Rarity ("Common")
-
There's a lot of code in this one, so make sure to pause and replay as much as you need :)
Follow the video to for an explanation on the Page Object Model
and Page Map Pattern
.
-
Within the Royale project, create a
Pages
directory. This is where all of our Page objects will live. -
Move the
Class1.cs
file intoPages
and rename it toHeaderNav.cs
-
Within the file, rename
Class1
toHeaderNav
and then make another class calledHeaderNavMap
-
Use PackSharp to restructure our packages and dependencies so we leverage Framework and Royale projects
- Move Selenium to the
Framework
project- Open Command Palette >
PackSharp: Bootstrap Selenium
> selectFramework
- Open Command Palette >
- Remove Selenium from
Royale.Tests
project- Open Command Palette >
PackSharp: Remove Package
> selectRoyale.Tests
> selectSelenium.Support
- Also remove
Selenium.WebDriver
- Open Command Palette >
- Move Selenium to the
-
Framework is our base, so we want the projects to reference each other in a linear way.
Framework -> Royale -> Royale.Tests
Royale.Tests
will referenceRoyale
which referencesFramework
- Open Command Palette >
PackSharp: Add Project Reference
> selectRoyale.Tests
> selectRoyale
- Open Command Palette >
PackSharp: Add Project Reference
> selectRoyale
> selectFramework
- Open Command Palette >
-
Now we can bring in
using OpenQA.Selenium
in ourHeaderNav.cs
file
NOTE: The rest of this video is very "code-heavy", so make sure to follow along there
-
The naming convention for Pages and Page Maps is very simple. If you have a Home page, then you would do this:
- Page =>
HomePage
- Map =>
HomePageMap
- Page =>
-
In the video, there is a "jump" from
11:22
to11:25
where I am able to use theMap.Card()
immediately. At this point, you will have an error. All you need to do is:- Add the
public readonly CardPageMap Map;
field at the top of theCardsPage
class and the Constructor as well - You will see the needed code at
11:43
. Sorry about that!
- Add the
-
Card Details Page and Map
- Take a moment to pause the video and copy the code to move forward
-
At the end of the video, run your second test. It should fail! Your challenge is to solve this error so the test passes.
-
Copy and Paste the second test to make a third test. However, this test will be for the "Mirror" card, so change the values in the test accordingly.
-
In the Framework project, make a new folder called
Models
and move theClass1.cs
file into it. -
Rename the file to
MirrorCard.cs
and change the class name too -
We'll be adding the card properties that we care about and give them default values.
NOTE: Some portions are sped up, so just pause the video when it gets back to regular speed and copy as needed.
-
Because
MirrorCard
andIceSpiritCard
share the same properties, we can create a "base"Card
with these properties and have our cards inherit it. This is very similar to how ourPageBase
class works. Every page on the website has access to theHeaderNav
, right? Instead of repeating the header navigation bar on every page, we can create it once and then share it to every page! This helps us avoid repeating ourselves as well as simplifying our code.- Copy and Paste the
MirrorCard
into a new file calledIceSpiritCard.cs
in Models. - Change the default values to the Ice Spirit values
- Copy and Paste the
-
Now we can bring this all together by creating a
GetBaseCard()
method in ourCardDetailsPage
. Pause the video as needed. -
We can use this method in the Mirror test by getting two cards:
var card = cardDetails.GetBaseCard();
- This card is the one we get off of the page using Selenium and our Page objects
var mirror = new MirrorCard();
- This card has the actual values that we expect our Mirror Card to have
-
Assert that the expected values from our
var mirror
card match the actual values we received fromvar card
. -
Our first Card Services
These "services" are abstractions of how we end up getting cards from a data store.
The "In Memory" card service will help with getting cards from local, hard-coded values we've specified in our Models directory, but ultimately we'd like to get these values from actual data stores like a database. We will be doing that in a future chapter :)
-
Now that the Services are complete and being used in the test, our 2nd and 3rd tests are almost identical. The only difference are the names!
- We can now leverage the
[TestCase]
and[TestSource]
attributes from theNUnit Test Framework
- These will "feed" values into the test
- This turns a single Test Method into multiple Test Cases!
- We can now leverage the
-
We can use the
[Parallelizable]
attribute to make these Test Cases run in parallel![Parallelizable(ParallelScope.Children)]
-
Run the 3rd test. It will spin up two browsers at the same time, but most likely both with fail. What happened?
- You will also notice that one of the two browsers doesn't close even after failing. Can you guess why?
- The answer is that we are instantiating a single WebDriver for both tests, so they are fighting each other over the driver!
- We will solve this in the next chapter
-
Last thing we'll do is add the
[Category]
attribute to our test[Category("cards")]
- We can use Categories when running our tests. To run only the tests with the Category of "cards", we would do:
$ dotnet test --filter testcategory=cards
NOTE: After the recording, they changed the way some of their pages loaded. This includes the Cards page, so we will need to add a wait.
// initialize a WebDriverWait
// make sure to bring in appropriate "using" statement
var wait = new WebDriverWait(_driver, TimeSpan.FromSeconds(10));
// you can use it in GetCardByName()
wait.Until(drvr => Map.Card(cardName).Displayed);
// or maybe in the CardsPageMap's Card(string name) property
wait.Until(drvr => drvr.FindElement(By.CssSelector($"a[href*='{name}']")))
-
Start by creating a
Selenium
folder in ourFramework
project -
Create a
Driver.cs
file inSelenium
. This is where our wrapper of WebDriver will exist. -
The key to achieving the simplicity of a
static
even though it's not a singleton is with the[ThreadStatic]
attribute.[ThreadStatic] private static IWebDriver _driver; public static void Init() { _driver = new ChromeDriver(); } public static IWebDriver Current => _driver ?? throw new System.ArgumentException("_driver is null.");
- This is the bread and butter of this approach.
_driver
is the instance of WebDriver on a thread. Current
is how you access the current instance of WebDriver for the test you're on
- This is the bread and butter of this approach.
-
In the
BeforeEach()
method of CardTests, you can now useDriver.Init();
and remove the "global"IWebDriver driver
at the top. -
Then create a "Pages Wrapper"
- Create
Pages.cs
file inRoyale.Pages
directory - Create a field for each page we have
[ThreadStatic] public static CardsPage Cards; [ThreadStatic] public static CardDetailsPage CardDetails; public static void Init() { Cards = new CardsPage(Driver.Current); CardDetails = new CardDetailsPage(Driver.Current); }
- Create
-
In the tests, we can get rid of any lines that say
new Page()
because our Pages Wrapper handles that for us- Replace:
var cardsPage = new CardsPage(Driver.Current);
- With:
Pages.Cards
- Replace:
-
In our
Driver
class, let's add a way to navigate to a URL so we don't have to sayDriver.Current.Url
public static void Goto(string url) { if (!url.StartsWith("http")) { url = $"http://{url}"; } Debug.WriteLine(url); Current.Navigate().GoToUrl(url); }
-
Your
BeforeEach()
method should now look like this:[SetUp] public void BeforeEach() { Driver.Init(); Pages.Init(); Driver.Goto("https://statsroyale.com"); }
-
Now add a
FindElement()
andFindElements()
method to Driver. -
Go to each of the Page Objects and replace
_driver
withDriver
.- We no longer need the
IWebDriver _driver
field in the Page Maps - We no longer need the constructors in the Page Maps
- We no longer need to pass in
IWebDriver driver
in Page Object constructors!
- We no longer need the
-
Yes, a lot of code is deleted, but that's a good thing!
-
The challenge is to add a
Quit()
method to our Driver class to get rid ofDriver.Current.Quit();
in theAfterEach()
.
-
From Postman, we saw the the /cards endpoint returned all of the cards that exist in the game. However, they had a different shape than our Card class. Update our Card class to include these new properties:
public class Card { public virtual string Id { get; set; } public virtual string Name { get; set; } public virtual string Icon { get; set; } public virtual int Cost { get; set; } public virtual string Rarity { get; set; } public virtual string Type { get; set; } public virtual string Arena { get; set; } }
-
We want to leverage that endpoint by creating an ApiCardService
- Create
ApiCardService.cs
withinFramework.Services
- Implement the
ICardService
interface
- Create
-
Install the
Newtonsoft.Json
package to theFramework
project- Open Command Palette >
PackSharp: Add Package
> searchNewtonsoft
> selectNewtonsoft.Json
- Open Command Palette >
-
Install the
RestSharp
package to theFramework
project- Open Command Palette >
PackSharp: Add Package
> searchRestSharp
> selectRestSharp
- Open Command Palette >
-
Within the
ApiCardService
class, we will use RestSharp to make the call just like PostMan. We'll call this methodGetAllCards()
public const string CARDS_API = "https://statsroyale.com/api/cards"; public IList<Card> GetAllCards() { var client = new RestClient(CARDS_API); var request = new RestRequest { Method = Method.GET, RequestFormat = DataFormat.Json }; var response = client.Execute(request); if (response.StatusCode != System.Net.HttpStatusCode.OK) { throw new System.Exception("/cards endpoint failed with " + response.StatusCode); } return JsonConvert.DeserializeObject<IList<Card>>(response.Content); }
-
Now let's implement the
GetCardByName(string cardName)
methodpublic Card GetCardByName(string cardName) { var cards = GetAllCards(); return cards.FirstOrDefault(card => card.Name == cardName); }
-
Now we can use the list of cards from the API rather than our hard-coded list of two cards. In CardTests, put this above our first test:
static IList<Card> apiCards = new ApiCardService().GetAllCards();
-
Our tests can now leverage this list of cards using the
[TestCaseSource]
attribute. Our first test should then look like:[Test, Category("cards")] [TestCaseSource("apiCards")] [Parallelizable(ParallelScope.Children)] public void Card_is_on_Cards_Page(Card card) { var cardOnPage = Pages.Cards.Goto().GetCardByName(card.Name); Assert.That(cardOnPage.Displayed); }
- We have added the "cards" Category to the test
- The TestCaseSource is now coming from "apiCards"
- We've added the same [Parallelizable] attribute to this test
- Changed the test name to better reflect that it tests all cards and not just Ice Spirit anymore
- The object we are passing into the test is not a
string
, but aCard
object.
-
Update the second test to also use
apiCards
-
Run the tests but use the
NUnit.NumberOfTestWorkers=2
argument so you don't overload your machine$ dotnet test --filter testcategory=cards -- NUnit.NumberOfTestWorkers=2
- This will only run two tests at a time which is probably best for your machine
- You can press
CTRL + C
in the terminal to cancel the test execution
-
CHALLENGE 1: After some test failures, you will see some interesting errors. The biggest one is that "Troop" is not equal to "tid_card_type_character" and "Spell" is not equal to "tid_card_type_spell".
The challenge is to solve this error.
HINT:
tid_card_type_spell
already has the word "spell" in it. Could we use that somehow? HINT:Troop
andcharacter
are the same thing in the context of the game. We should treat characters as troops and vice-versa
- CHALLENGE 2: Similar to Challenge 1, tests will be failing because "Arena 8" is not equal to
8
The challenge is to solve this error.
HINT: A string of
"8"
is different than an integer of8
HINT: Be aware that some cards have an Arena of0
because they are playable in the "Training Camp". This is why we need to understand the data we're working with. Otherwise, you wouldn't be testing for those values! Check out theBaby Dragon
card.
Since the recording, they have slightly changed the way their pages load. Because of this, you may experience "flakiness" because we are lacking any waits. We will be discussing waits in Chapter 7 and 12.
-
Make a new suite of tests by creating a new test file called
CopyDeckTests.cs
-
Within this file, we'll copy and paste our SetUp and TearDown from the
CardTests.cs
-
Then we can write a new test that checks whether a user can copy a deck
[Test] public void User_can_copy_the_deck() { // 2. go to Deck Builder page Driver.FindElement(By.CssSelector("[href='/deckbuilder']")).Click(); // 3. Click "add cards manually" Driver.FindElement(By.XPath("//a[text()='add cards manually']")).Click(); // 4. Click Copy Deck icon Driver.FindElement(By.CssSelector(".copyButton")).Click(); // 5. Click Yes Driver.FindElement(By.Id("button-open")).Click(); // 6. Assert the "if click Yes..." message is displayed var copyMessage = Driver.FindElement(By.CssSelector(".notes.active")); Assert.That(copyMessage.Displayed); }
-
With the test written, we can now refactor this into our Page Map Pattern.
- Create a
DeckBuilderPage.cs
file withinRoyale.Pages
- Create the Page Map
public class DeckBuilderPageMap { public IWebElement AddCardsManuallyLink => Driver.FindElement(By.CssSelector("")); public IWebElement CopyDeckIcon => Driver.FindElement(By.XPath("//a[text()='add cards manually']")); }
- Then create the Page
public class DeckBuilderPage : PageBase { public readonly DeckBuilderPageMap Map; public DeckBuilderPage() { Map = new DeckBuilderPageMap(); } public DeckBuilderPage Goto() { HeaderNav.Map.DeckBuilderLink.Click(); return this; } public void AddCardsManually() { Map.AddCardsManuallyLink.Click(); } public void CopySuggestedDeck() { Map.CopyDeckIcon.Click(); } }
- Create a
-
Create the
CopyDeckPage.cs
file -
Add these pages to the Pages Wrapper class in
Pages.cs
. Your Pages class should now look like thispublic class Pages { [ThreadStatic] public static CardsPage Cards; [ThreadStatic] public static CardDetailsPage CardDetails; [ThreadStatic] public static DeckBuilderPage DeckBuilder; [ThreadStatic] public static CopyDeckPage CopyDeck; public static void Init() { Cards = new CardsPage(); CardDetails = new CardDetailsPage(); DeckBuilder = new DeckBuilderPage(); CopyDeck = new CopyDeckPage(); } }
-
Use these pages in the test
[Test] public void User_can_copy_the_deck() { Pages.DeckBuilder.Goto(); Pages.DeckBuilder.AddCardsManually(); Pages.DeckBuilder.CopySuggestedDeck(); Pages.CopyDeck.Yes(); Assert.That(Pages.CopyDeck.Map.CopiedMessage.Displayed); }
-
If you run the test, it fails pretty quickly! That's because the test in the code is running faster than the page is moving. We need to use
WebDriverWait
. Add this to the top of our test.var wait = new WebDriverWait(Driver.Current, TimeSpan.FromSeconds(10));
-
After
Pages.DeckBuilder.AddCardsManually
addwait.Until(drvr => Pages.DeckBuilder.Map.CopyDeckIcon.Displayed);
-
Add another wait after
Pages.CopyDeck.Yes
wait.Until(drvr => Pages.CopyDeck.Map.CopiedMessage.Displayed);
-
Just like how we created our own Driver class to "extend" WebDriver, we will do the same for WebDriverWait.
- Create a
Wait.cs
file inFramework.Selenium
public class Wait { private readonly WebDriverWait _wait; public Wait(int waitSeconds) { _wait = new WebDriverWait(Driver.Current, TimeSpan.FromSeconds(waitSeconds)) { PollingInterval = TimeSpan.FromMilliseconds(500) }; _wait.IgnoreExceptionTypes( typeof(NoSuchElementException), typeof(ElementNotVisibleException), typeof(StaleElementReferenceException) ); } public bool Until(Func<IWebDriver, bool> condition) { return _wait.Until(condition); } }
- Create a
-
Include our new Wait to the Driver class
[ThreadStatic] private static IWebDriver _driver; [ThreadStatic] public static Wait Wait; public static void Init() { _driver = new ChromeDriver(Path.GetFullPath(@"../../../../" + "_drivers")); Wait = new Wait(10); }
-
In our test, replace
wait.Until()
with our new Wait. For example:Driver.Wait.Until(drvr => Pages.DeckBuilder.Map.CopyDeckIcon.Displayed);
-
CHALLENGE 7a: On the
DeckBuilderPage
, the Page Map has two elements that may have incorrect locators. You probably didn't spot this!
Part 1 of the challenge is to validate that they work in the DevTools Console and fix them if needed.
Also, you may need to add another wait for the AddCardsManually()
step.
Part 2 of the challenge is to add this missing wait to the test.
-
Refactor the Waits from our
User_can_copy_deck()
test into their respective actions. For example, theAddCardsManually()
method should now look like:public void AddCardsManually() { Map.AddCardsManuallyLink.Click(); Driver.Wait.Until(drvr => Map.CopyDeckIcon.Displayed); }
-
Write the next two tests with how we want them to eventuall look like. This is kind of a TDD approach:
[Test] public void User_opens_app_store() { Pages.DeckBuilder.Goto().AddCardsManually(); Pages.DeckBuilder.CopySuggestedDeck(); Pages.CopyDeck.No().OpenAppStore(); Assert.That(Driver.Title, Is.EqualTo("Clash Royale on the App Store")); } [Test] public void User_opens_google_play() { Pages.DeckBuilder.Goto().AddCardsManually(); Pages.DeckBuilder.CopySuggestedDeck(); Pages.CopyDeck.No().OpenGooglePlay(); Assert.AreEqual("Clash Royale - Apps on Google Play", Driver.Title); }
- Change the return type of our
DeckBuilder.Goto()
method so we can "chain"AddCardsManually()
- From
void
toDeckBuilderPage
and addingreturn this;
public DeckBuilder Goto() { HeaderNav.Map.DeckBuilderLink.Click(); Driver.Wait.Until(drvr => Map.AddCardsManuallyLink.Displayed); return this; }
- Change the return type of our
-
You will have some errors like a red squiggly under
No()
because we haven't implemented it yet. Let's do that! In theCopyDeckPage
, add a No() method:public CopyDeckPage No() { Map.NoButton.Click(); return this; }
return this;
returns the current instance of itself - the CopyDeckPage- Notice how the return type is a
CopyDeckPage
- This allows us to "chain" commands and actions like we did in Step 2!
-
Add the
OpenAppStore()
andOpenGooglePlay()
methods as wellpublic void OpenAppStore() { Map.AppStoreButton.Click(); } public void OpenGooglePlay() { Map.GooglePlayButton.Click(); }
- Find the elements and add them to the
CopyDeckPageMap
so you can access them in the above methods
- Find the elements and add them to the
-
Fix the last error which is
Driver.Title
in the test. Like the error suggests, ourDriver
class doesn't have aTitle
property. Let's add it!public static string Title => Current.Title;
-
The Accept Cookies banner at the bottom of the Copy Deck page will overlap our App Store buttons. We need to accept this to remove the banner. Add this method:
public void AcceptCookies() { Map.AcceptCookiesButton.Click(); Driver.Wait.Until(drvr => !Map.AcceptCookiesButton.Displayed); }
- Find the element and add it to the Map
- The
AcceptCookies()
method will click the button and then wait for it to disappear (aka NOT BE displayed)
-
Add the AcceptCookies() to our No()
public CopyDeckPage No() { Map.NoButton.Click(); AcceptCookies(); Driver.Wait.Until(drvr => Map.OtherStoresButton.Displayed); return this; }
- We also added a Wait to make sure the
OtherStoresButton
is displayed first before proceeding
- We also added a Wait to make sure the
-
CHALLENGE 7b: At the end of the video, I show you that our
User_opens_app_store()
fails because of a string comparison issue:
"Clash Royale on the App Store"
is not equal to
"Clash Royale on the App Store"
They look identical, but there is a big difference. The string we get back from Driver.Title
has a unicode character at the beginning!
- The challenge is to solve this error
NOTE: There are many ways to approach this, but remember that this is String Comparison
- Create a
FW.cs
in the Framework project
-
This is going to hold objects that are used throughout our Framework and projects
-
We'll start with the the workspace directory and test results directory
public static string WORKSPACE_DIRECTORY = Path.GetFullPath(@"../../../../"); public static DirectoryInfo CreateTestResultsDirectory() { var testDirectory = WORKSPACE_DIRECTORY + "TestResults"; if (Directory.Exists(testDirectory)) { Directory.Delete(testDirectory, recursive: true); } return Directory.CreateDirectory(testDirectory); }
-
Create a
Logging
directory with theFramework
project and create a file calledLogger.cs
-
The Logger class will handle the
log.txt
and writing to thempublic class Logger { private readonly string _filepath; public Logger(string testName, string filepath) { _filepath = filepath; using (var log = File.CreateText(_filepath)) { log.WriteLine($"Starting timestamp: {DateTime.Now.ToLocalTime()}"); log.WriteLine($"Test: {testName}"); } } public void Info(string message) { WriteLine($"[INFO]: {message}"); } public void Step(string message) { WriteLine($" [STEP]: {message}"); } public void Warning(string message) { WriteLine($"[WARNING]: {message}"); } public void Error(string message) { WriteLine($"[ERROR]: {message}"); } public void Fatal(string message) { WriteLine($"[FATAL]: {message}"); } private void WriteLine(string text) { using (var log = File.AppendText(_filepath)) { log.WriteLine(text); } } private void Write(string text) { using (var log = File.AppendText(_filepath)) { log.Write(text); } } }
WriteLine()
andWrite()
will make writing to the correct log.txt file a piece of cake- Different "log types" are used depending on the type of message we want to display
- Info
- Step
- Warning
- Error
- Fatal
- Whenever we make a new instance of Logger(), it will create a new log file using the
testName
andfilepath
that are passed.
-
Our
Framework
project needs to use NUnit, so we will use PackSharp
-
Open Command Palette >
PackSharp: Add Package
> selectFramework
> search "NUnit" > selectNUnit
-
Open Command Palette >
PackSharp: Remove Package
> selectRoyale.Tests
> selectNUnit
-
Build solution to make sure things are still structured ok:
$ dotnet clean $ dotnet restore $ dotnet build
-
Add a
SetLogger()
method to ourFW
class to create the Logger per test. We'll also need some fields and properties to hold these values. We will also use alock
to solve any "race conditions":public static Logger Log => _logger ?? throw new NullReferenceException("_logger is null. SetLogger() first."); [ThreadStatic] public static DirectoryInfo CurrentTestDirectory; [ThreadStatic] private static Logger _logger; public static void SetLogger() { lock (_setLoggerLock) { var testResultsDir = WORKSPACE_DIRECTORY + "TestResults"; var testName = TestContext.CurrentContext.Test.Name; var fullPath = $"{testResultsDir}/{testName}"; if (Directory.Exists(fullPath)) { CurrentTestDirectory = Directory.CreateDirectory(fullPath + TestContext.CurrentContext.Test.ID); } else { CurrentTestDirectory = Directory.CreateDirectory(fullPath); } _logger = new Logger(testName, CurrentTestDirectory.FullName + "/log.txt"); } } private static object _setLoggerLock = new object();
-
In the Test Suites,
CopyDeckTests
andCardTests
, add a[OneTimeSetup]
method and update the[SetUp]
method:[OneTimeSetUp] public void BeforeAll() { FW.CreateTestResultsDirectory(); } [SetUp] public void BeforeEach() { FW.SetLogger(); Driver.Init(); Pages.Init(); Driver.Goto("https://statsroyale.com"); }
[OneTimeSetUp]
is run before any tests. This will create theTestResults
directory for the test run.FW.SetLogger()
will create an instance of Logger, which creates a log.txt file, for each test.
-
Run the tests
-
Open the new
TestResults
directory. You will see that there are directories created for each test and each test has its ownlog.txt
file!
-
Create an
Element.cs
underFramework.Selenium
public class Element : IWebElement { private readonly IWebElement _element; public readonly string Name; public By FoundBy { get; set; } public Element(IWebElement element, string name) { _element = element; Name = name; } public IWebElement Current => _element ?? throw new System.NullReferenceException("_element is null."); public string TagName => Current.TagName; public string Text => Current.Text; public bool Enabled => Current.Enabled; public bool Selected => Current.Selected; public Point Location => Current.Location; public Size Size => Current.Size; public bool Displayed => Current.Displayed; public void Clear() { Current.Clear(); } public void Click() { FW.Log.Step($"Click {Name}"); Current.Click(); } public IWebElement FindElement(By by) { return Current.FindElement(by); } public ReadOnlyCollection<IWebElement> FindElements(By by) { return Current.FindElements(by); } public string GetAttribute(string attributeName) { return Current.GetAttribute(attributeName); } public string GetCssValue(string propertyName) { return Current.GetCssValue(propertyName); } public string GetProperty(string propertyName) { return Current.GetProperty(propertyName); } public void SendKeys(string text) { Current.SendKeys(text); } public void Submit() { Current.Submit(); } }
- Our class
Element
will implement theIWebElement
interface - Just like our Driver,
Current
will represent the current instance of the IWebElement we're extending - Add logging to our
Click()
method
- Our class
-
Our Driver should now return
Element
instead ofIWebElement
public static Element FindElement(By by, string elementName) { return new Element(Current.FindElement(by), elementName) { FoundBy = by }; }
-
Go to each of our Pages and change our Maps to use
Element
instead ofIWebElement
and give each element a name. For example, inHeaderNav.cs
:public class HeaderNavMap { public Element CardsTabLink => Driver.FindElement(By.CssSelector("a[href='/cards']"), "Cards Link"); public Element DeckBuilderLink => Driver.FindElement(By.CssSelector("a[href='/deckbuilder']"), "Deck Builder Link"); }
NOTE: The
elementName
we pass in, like "Cards Link", is used for logging purposes -
Now create
Elements.cs
underFramework.Selenium
public class Elements : ReadOnlyCollection<IWebElement> { private readonly IList<IWebElement> _elements; public Elements(IList<IWebElement> list) : base(list) { _elements = list; } public By FoundBy { get; set; } public bool IsEmpty => Count == 0; }
- Our class already has access to things like
Count
because it's inheriting fromReadOnlyCollection<IWebElement>
- We already have the functionality of a list, but now we can add our own!
FoundBy
andIsEmpty
are examples of this
- Our class already has access to things like
-
Our Driver should return
Elements
instead ofIList<IWebElement>
public static Elements FindElements(By by) { return new Elements(Current.FindElements(by)) { FoundBy = by }; }
-
CHALLENGE: Our Element class can now have any functionality we want! This is a very powerful way for you to control what you can and cannot do with your elements.
- The challenge is to add a
Hover()
method so that each element simply call .Hover()
HINT: The Actions class is in
OpenQA.Selenium.Interactions
-
Create
DriverFactory.cs
underFramework.Selenium
public static class DriverFactory { public static IWebDriver Build(string browserName) { FW.Log.Info($"Browser: {browserName}"); switch (browserName) { case "chrome": return new ChromeDriver(FW.WORKSPACE_DIRECTORY + "_drives"); case "firefox": return new FirefoxDriver(); default: throw new System.ArgumentException($"{browserName} not supported."); } } }
- This handles creating our ChromeDriver, but also allows you to define how you want your different drivers to be created
-
Use this is our Driver
public static void Init(string browserName) { _driver = DriverFactory.Build(browserName); Wait = new Wait(10); }
-
Update our
[SetUp]
methods since we now require a browserName inDriver.Init()
[SetUp] public void BeforeEach() { FW.SetLogger(); Driver.Init("chrome"); Pages.Init(); Driver.Goto("https://statsroyale.com"); }
-
Create a
framework-config.json
at the workspace root{ "driver": { "browser": "chrome" }, "test": { "url": "staging.statsroyale.com" } }
-
Create
FwConfig.cs
underFramework
to represent the json in code:public class FwConfig { public DriverSettings Driver { get; set; } public TestSettings Test { get; set; } } public class DriverSettings { public string Browser { get; set; } } public class TestSettings { public string Url { get; set; } }
DriverSettings
represents the "driver" object in the jsonTestSettings
represents the "test" object in the jsonFwConfig
represents the entire config file
-
Add a "singleton" representation of our config in the
FW
classpublic static FwConfig Config => _configuration ?? throw new NullReferenceException("Config is null. Call FW.SeConfig() first."); private static FwConfig _configuration;
-
Now add the
SetConfig()
methodpublic static void SetConfig() { if (_configuration == null) { var jsonStr = File.ReadAllText(WORKSPACE_DIRECTORY + "/framework-config.json"); _configuration = JsonConvert.DeserializeObject<FwConfig>(jsonStr); } }
- We check if the _configuration is null
- if null, then set it
- if not null, then it has already been set
- We are getting the .json file and turning it into a json string
- Then we deserialize it into the shape of our
FwConfig
so it can be used in code
- We check if the _configuration is null
-
Go back to our Driver class and use the config
public static void Init() { _driver = DriverFactory.Build(FW.Config.Driver.Browser); Wait = new Wait(10); }
-
Update our test files to use the config too
[OneTimeSetUp] public void BeforeAll() { FW.SetConfig(); FW.CreateTestResultsDirectory(); } [SetUp] public void BeforeEach() { FW.SetLogger(); Driver.Init(); Pages.Init(); Driver.Goto(FW.Config.Test.Url); }
FW.SetConfig();
in the [OneTimeSetUp]Driver.Goto(FW.Config.Test.Url);
in the [SetUp]
-
Run the
copydeck
tests -
They failed! Take a look at the log files to see what happened
- Our test logged this:
[INFO]: http://staging.statsroyale.com
- It tried navigating to a "staging" version of the site, but we don't have access
- Our test logged this:
-
Update the
test.url
value in ourframework-config.json
{ "driver": { "browser": "chrome" }, "test": { "url": "statsroyale.com" } }
-
CHALLENGE: I bet you already have some ideas for things we could put into our framework-config.json. For this challenge, add a "wait" property to hold the default number of seconds that we want to use when instantiating our Wait class.
HINT: We're instantiating Wait in Driver.Init()
-
Create a
Base
folder withinRoyale.Tests
-
Create
TestBase.cs
within thatBase
folder -
In one of our test suites, copy the
[OneTimeSetUp]
,[SetUp]
and[TearDown]
methods and paste them into our TestBase classpublic abstract class TestBase { [OneTimeSetUp] public virtual void BeforeAll() { FW.SetConfig(); FW.CreateTestResultsDirectory(); } [SetUp] public virtual void BeforeEach() { FW.SetLogger(); Driver.Init(); Pages.Init(); Driver.Goto(FW.Config.Test.Url); } [TearDown] public virtual void AfterEach() { Driver.Quit(); } }
- TestBase is an abstract class
-
In our test suites, delete the
[OneTimeSetUp]
,[SetUp]
and[TearDown]
methods -
Our test classes should now inherit TestBase
public class CopyDeckTests : TestBase
-
Run a test and you'll see nothing has changed!
-
In our
[TearDown]
method, now in TestBase, we'll handle the outcome of the test[TearDown] public virtual void AfterEach() { var outcome = TestContext.CurrentContext.Result.Outcome.Status; if (outcome == TestStatus.Passed) { FW.Log.Info("Outcome: Passed"); } else if (outcome == TestStatus.Failed) { FW.Log.Info("Outcome: Failed"); } else { FW.Log.Warning("Outcome: " + outcome); } Driver.Quit(); }
-
We want to take a screenshot if our test fails. Let's add that functionality somewhere in our Driver class
public static void TakeScreenshot(string imageName) { var ss = ((ITakesScreenshot)Current).GetScreenshot(); var ssFileName = Path.Combine(FW.CurrentTestDirectory.FullName, imageName); ss.SaveAsFile($"{ssFileName}.png", ScreenshotImageFormat.Png); }
-
Now add
TakeScreenshot()
when our test failselse if (outcome == TestStatus.Failed) { Driver.TakeScreenshot("test_failed"); FW.Log.Info("Outcome: Failed"); }
-
Run a test that fails. You may need to force a failure with
Assert.Fail();
-
Open the test's result directory
- There should be a
test_failed.png
along with thelog.txt
- There should be a
We touched on this in Chapter 2. For Windows, it's pretty easy:
public IWebDriver BuildChrome()
{
var options = new ChromeOptions();
options.AddArgument("--start-maximized");
return new ChromeDriver(options);
}
or
driver.Manage().Window.Maximize();
However, this may not work for Mac or Linux. Let's go over a method that works cross-platform.
-
Create
Window.cs
underFramework.Selenium
-
We'll add a few methods to start out
public ReadOnlyCollection<string> CurrentWindows => Driver.Current.WindowHandles; public void SwitchTo(int windowIndex) { Driver.Current.SwitchTo().Window(CurrentWindows[windowIndex]); }
-
Add a property to get the current ScreenSize so we know the amount of space we're working with
NOTE: This is different than what's in the video because of changes made to .NET Standard and Selenium
```c#
public Size ScreenSize
{
get
{
var js = "return [window.screen.availWidth, window.screen.availHeight];";
var jse = (IJavaScriptExecutor)Driver.Current;
dynamic dimensions = jse.ExecuteScript(js, null);
var x = Convert.ToInt32(dimensions[0]);
var y = Convert.ToInt32(dimensions[1]);
return new Size(x, y);
}
}
```
-
Create a
Maximize()
method that uses the ScreenSizepublic void Maximize() { Driver.Current.Manage().Window.Position = new Point(0, 0); Driver.Current.Manage().Window.Size = ScreenSize; }
-
Add the Window class to our Driver
[ThreadStatic] public static Window Window; public static void Init() { _driver = DriverFactory.Build(FW.Config.Driver.Browser); Wait = new Wait(FW.Config.Driver.WaitSeconds); Window = new Window(); Window.Maximize(); }
-
Run a test. It should spin up a browser and then maximize!
-
Install the
Selenium Extras
package with PackSharp- Open Command Palette >
PackSharp: Add Package
> selectFramework
> search "Selenium Extras" > selectDotNetSeleniumExtras.WaitHelpers
- Open Command Palette >
-
Use ExpectedConditions in our No() method
NOTE: Make sure to use it from the
SeleniumExtras.WaitHelpers
and NOT fromOpenQA.Selenium.Support.UI
```c#
Driver.Wait.Until(ExpectedConditions.ElementIsVisible(Map.OtherStoresButton.FoundBy));
```
-
We need to solve the error by adding an overload of Until() in our Wait class
public IWebElement Until(Func<IWebDriver, IWebElement> condition) { return _wait.Until(condition); }
-
Our No() method should now look like this
public CopyDeckPage No() { Map.NoButton.Click(); AcceptCookies(); Driver.Wait.Until(ExpectedConditions.ElementIsVisible(Map.OtherStoresButton.FoundBy)); return this; }
-
Create
WaitConditions.cs
underFramework.Selenium
public sealed class WaitConditions { public static Func<IWebDriver, bool> ElementDisplayed(IWebElement element) { bool condition(IWebDriver driver) { return element.Displayed; } return condition; } }
-
Use our WaitConditions in the DeckBuilderPage's
AddCardsManually()
methodpublic void AddCardsManually() { Driver.Wait.Until(WaitConditions.ElementDisplayed(Map.AddCardsManuallyLink)); Map.AddCardsManuallyLink.Click(); Driver.Wait.Until(WaitConditions.ElementDisplayed(Map.CopyDeckIcon)); }
-
We also need to handle elements that need to disappear. Let's add it to our WaitConditions class
public static Func<IWebDriver, bool> ElementNotDisplayed(IWebElement element) { bool condition(IWebDriver driver) { try { return !element.Displayed; } catch (StaleElementReferenceException) { return true; } } return condition; }
-
Now we'll use this in our
AcceptCookies()
method in the CopyDeckPagepublic void AcceptCookies() { Map.AcceptCookiesButton.Click(); Driver.Wait.Until(WaitConditions.ElementNotDisplayed(Map.AcceptCookiesButton)); }
-
With our Waits, we can return more than just booleans. Let's create a WaitCondition that returns a list of Elements once at least one of them is found. Add this to our WaitConditions class:
public static Func<IWebDriver, Elements> ElementsNotEmpty(Elements elements) { Elements condition(IWebDriver driver) { Elements _elements = Driver.FindElements(elements.FoundBy); return _elements.IsEmpty ? null : _elements; } return condition; }
-
Add another Wait that returns an element instead of just bool. This WaitCondition will solve us waiting for an element to be displayed on one line, and then clicking it in the next line.
public static Func<IWebDriver, IWebElement> ElementIsDisplayed(IWebElement element) { IWebElement condition(IWebDriver driver) { try { return element.Displayed ? element : null; } catch (NoSuchElementException) { return null; } catch (ElementNotVisibleException) { return null; } } return condition; }
-
Now let's use it in the AddCardsManually() method
public void AddCardsManually() { Driver.Wait.Until( WaitConditions.ElementIsDisplayed(Map.AddCardsManuallyLink)) .Click(); Driver.Wait.Until(WaitConditions.ElementDisplayed(Map.CopyDeckIcon)); }
-
CHALLENGE: I didn't run the test just so we could do this challenge :)
When working with web elements, we usually only work with elements that we expect to be present on the DOM. At least, that's what our users do. Because of this, it makes sense for us to "wait" for elements to at least be present in the DOM before returning it.
In this challenge, you need to add a "Wait" to our Driver.FindElement()
method so every time we "find" an element, we wait until it exists before returning it.
HINT: Give it a try, but you can look at the last commit to see how this is done. HINT: Your tests will most likely fail on the
AddCardsManually()
step because when we callMap.AddCardsManuallyButton
, we are having the Driver find the element at that time instead of within a Wait. Solving this challenge will also solve that issue.
That's it! You are now done with the course :)