Monday, January 16, 2012

BDD With Spock and Selenium






BDD With Spock and Selenium


Goal

To develop a web site specification-first using Spock and Selenium WebDriver.


tl;dr

The sample code for this project is available on github and will be a helpful resource for following this article. The interesting technologies showcased include Spock, Selenium WebDriver, Selenium PageObjects, Sauce OnDemand, Gradle and Grails 2.0.


The Project

Suppose our product owner would like to publish a website for teachers to schedule their classes and for students to register for those courses. At the first backlog grooming, the product owner has prioritized the stories that would constitute a minimum viable product.


User Stories

During the team's first sprint planning meeting, we sized the stories, accepted four into the sprint, and have added tasks for each. Our story board for the first sprint contains sticky notes for our user stories, the acceptance criteria and the individual tasks for each story.



As a teacher
I want to sign up
So I can add courses


Acceptance Criteria:
  • User is greeted with intro screen
  • User is able to register as a teacher or to login
  • After registration or login, a teacher sees account info

As a student
I want to sign up
So I can take courses


Acceptance Criteria:
  • User is able to register or to login as a student
  • After registration or login, a student sees account info

As a teacher
I want to add courses
So students can register for them


Acceptance Criteria:
  • A teacher can add a course
  • Courses occur in a semester
  • Courses occur in timeslots
  • Courses can have prerequisites

As a student
I want to register for courses
So I can get credits toward my degree


Acceptance Criteria:
  • A student can register for a course
  • A student must take any pre-reqs before registering for a course
  • A student cannot take 2 courses that occur in the same timeslot

Project Structure

We will create a top-level project course_registry and the two sub-projects web and specifications. We will also create a build.gradle file in the root along with settings.gradle and a build.gradle file in the specifications subproject for running automated acceptance tests (While this is not strictly necessary, it will help to keep our project boundaries clear).


  course_registry
   .
  |____build.gradle
  |____settings.gradle
  | specifications
  | |____build.gradle
  | web


course_registry/build.gradle
allprojects {
  apply plugin: 'eclipse'
  version = '1.0.0-SNAPSHOT'
  group = 'timezra.course_registry'
}

subprojects {
  sourceCompatibility = 1.6
}


course_registry/settings.gradle
include 'web', 'specifications'


course_registry/specifications/build.gradle
apply plugin: 'groovy'

repositories {
  mavenCentral()
  mavenRepo url: "http://m2repo.spockframework.org/snapshots"
}

dependencies {
  groovy group: 'org.codehaus.groovy', name: 'groovy', version: '1.8.5'
  testCompile group: 'org.spockframework', name: 'spock-core', version: '0.6-groovy-1.8-SNAPSHOT'
  testCompile group: 'org.seleniumhq.selenium', name: 'selenium-java', version: '2.16.1'
  testCompile group: 'junit', name: 'junit', version: '4.10'
}

We will create a src/test/groovy folder in the specifications project. At this point we can generate Eclipse .project and .classpath files for all the projects from the project root in order to work within an IDE.


course_registry $> gradle eclipseProject
course_registry $> gradle eclipseClasspath


The Teacher Registration Spec

We are now able to write the first specification for our acceptance criteria.


timezra/course_registry/TeacherRegistrationSpec.groovy
package timezra.course_registry

import static java.util.concurrent.TimeUnit.SECONDS
import org.openqa.selenium.WebDriver
import org.openqa.selenium.firefox.FirefoxDriver
import spock.lang.Specification

class TeacherRegistrationSpec extends Specification {

  WebDriver driver

  def setup() {
    driver = new FirefoxDriver()
    driver.manage().timeouts().implicitlyWait 10, SECONDS
  }

  def cleanup() {
    driver.quit()
  }

  def "a user is greeted with an intro screen"() {
    when:
    driver.get "http://localhost:8080/course_registry"

    then:
    driver.title == "Course Registry Home"
  }
}


We can run this spec and watch it fail.

course_registry $> gradle test --info
....
Test a user is greeted with an intro screen(timezra.course_registry.TeacherRegistrationSpec) FAILED: org.gradle.messaging.remote.internal.PlaceholderException: org.spockframework.runtime.SpockComparisonFailure: Condition not satisfied:

driver.title == "Course Registry Home"
|      |     |
|      |     false
|      |     17 differences (15% similarity)
|      |     (Pr)o(bl-)e(m) (load)i(ng--) (pag)e
|      |     (C-)o(urs)e(-) (Reg-)i(stry) (Hom)e
|      Problem loading page
org.openqa.selenium.firefox.FirefoxDriver@4532be10

Test timezra.course_registry.TeacherRegistrationSpec FAILED
Gradle Worker 1 finished executing tests.
1 test completed, 1 failure

FAILURE: Build failed with an exception.
....

Satisfying the Spec

Suppose the team decides to use Grails 2.0 to implement the specification. This is not a restriction based on our other technology choices, since gradle is a general purpose build tool and since our specification defines how a user will interact with our web project entirely through a browser. The decision is based on the convenience of the framework, community support, the plug-in ecosystem and the skills of the developers.

First, we need to create an empty web/grails-app directory to indicate to the grails bootstrap that the project will be a grails application.
Then, we will configure our web build for Grails 2.0 in a new web/build.gradle file.

web/build.gradle
buildscript {
  repositories {
    mavenCentral()
    mavenRepo url: 'https://repository.jboss.org/nexus/content/groups/public/'
    mavenRepo url: 'http://repo.grails.org/grails/repo'
  }

  dependencies {
    classpath group: 'org.grails', name: 'grails-gradle-plugin', version: '1.1.0'
  }
}

grailsVersion = '2.0.0'
apply plugin: 'grails'

dependencies {
  compile group: 'org.grails', name: 'grails-resources', version: grailsVersion
  compile group: 'org.grails', name: 'grails-crud', version: grailsVersion
  compile group: 'org.grails', name: 'grails-hibernate', version: grailsVersion
  compile group: 'org.grails', name: 'grails-plugin-datasource', version: grailsVersion
  runtime group: 'org.grails', name: 'grails-plugin-log4j', version: grailsVersion
  runtime group: 'org.grails', name: 'grails-plugin-url-mappings', version: grailsVersion
  runtime group: 'org.grails', name: 'grails-plugin-gsp', version: grailsVersion
  runtime group: 'org.grails', name: 'grails-plugin-filters', version: grailsVersion
  runtime group: 'org.grails', name: 'grails-plugin-scaffolding', version: grailsVersion
  runtime group: 'org.grails', name: 'grails-plugin-services', version: grailsVersion
  runtime group: 'org.grails', name: 'grails-plugin-servlets', version: grailsVersion
  runtime group: 'com.h2database', name: 'h2', version: '1.3.163'
  runtime group: 'net.sf.ehcache', name: 'ehcache-core', version: '2.4.6'
}

repositories {
  mavenCentral()
  mavenRepo url: 'https://repository.jboss.org/nexus/content/groups/public/'
  mavenRepo url: 'http://repo.grails.org/grails/repo'
}

From the project root we will initialize the grails project.

course_registry $> gradle grails-init
:web:grails-init
The ResolvedArtifact.getResolvedDependency() method is deprecated and will be removed in the next version of Gradle.
| Configuring classpath
| Error log4j:WARN No appenders could be found for logger (org.springframework.core.io.support.PathMatchingResourcePatternResolver).
| Error log4j:WARN Please initialize the log4j system properly.
| Error log4j:WARN See http://logging.apache.org/log4j/1.2/faq.html#noconfig for more info.
| Environment set to development.....

BUILD SUCCESSFUL

Total time: 1 mins 36.328 secs

We can satisfy the spec simply by modifying application.properties with the expected web application deployment path and project version, the homepage to include the expected title, and messages.properties to contain the expected messages.


web/application.properties
app.grails.version=2.0.0
app.name=course_registry
app.servlet.version=2.5
app.version=1.0.0-SNAPSHOT


web/grails-app/views/index.gsp
<html>
  <head>
    <title><g:message code="home.title" /></title>
    <meta name="layout" content="main" />
    <style type="text/css" media="screen">
      #pageBody {
        margin-left280px;
        margin-right20px;
       }
    </style>
  </head>
  <body>
    <div id="pageBody" class="dialog">
     <p>
      <g:message code="home.welcome.message" />
     </p>
    </div>
  </body>
</html>


web/grails-app/i18n/messages.properties
....
home.title=Course Registry Home
home.welcome.message=Welcome to the course registry.


We can now add hooks to the specifications project to stop and start the web application before and after running the specifications, respectively.

specifications/build.gradle
....
test.dependsOn ':web:webStart'

gradle.taskGraph.afterTask { Task task, TaskState state ->
  if(':specifications:test' == task.path) {
    project(':web').tasks.getByPath('webStop').execute()
  }
}


These hooks depend on a specific interface in the web project, i.e., the existence of the tasks webStart and webStop. We can deploy the Grails 2.0 artifact to an embedded Jetty server through gradle to satisfy this interface.


web/build.gradle
....
apply plugin: 'jetty'

def stopPort = 8001
def safeWord = 'banana'

task webStart(dependsOn: 'grails-war') << {
  def jettyRunWar = tasks.getByPath('jettyRunWar')
  jettyRunWar.webApp = new File(projectDir, "target/course_registry-${version}.war")
  jettyRunWar.contextPath = 'course_registry'
  jettyRunWar.daemon = true
  jettyRunWar.stopPort = stopPort
  jettyRunWar.stopKey = safeWord
  jettyRunWar.execute()
}

task webStop << {
  def jettyStop = tasks.getByPath('jettyStop')
  jettyStop.stopPort = stopPort
  jettyStop.stopKey = safeWord
  jettyStop.execute()
}
....

The first specification should now be satisfied.


course_registry $> gradle test --info
....
Started Jetty Server
Gradle Worker 1 executing tests.
Test a user is greeted with an intro screen(timezra.course_registry.TeacherRegistrationSpec) PASSED
Gradle Worker 1 finished executing tests.
....


NB: The Jetty plugin must be applied before the grails plugin, or else you will see an error similar to the following:
FAILURE: Build failed with an exception.

* Where:
Build file '/path/to/course_registry/web/build.gradle' line: 38

* What went wrong:
A problem occurred evaluating project ':web'.
Cause: Cannot add task ':web:clean' as a task with that name already exists.

* Try:
Run with --stacktrace option to get the stack trace. Run with --info or --debug option to get more log output.

BUILD FAILED

Total time: 7.624 secs

Adding another test for our second acceptance criterion should be straightforward now that our infrastructure is in place.

timezra/course_registry/TeacherRegistrationSpec.groovy
  ....
  def "a user can register as a teacher"() {
    when:
    driver.get "http://localhost:8080/course_registry"
    WebElement teacherLink = driver.findElement(By.id('teacher_link'))
    teacherLink.click()
    WebElement name = driver.findElement(By.id('name'))
    type name, 'John Doe'
    WebElement email = driver.findElement(By.id('email'))
    type email, "${UUID.randomUUID()}@rutgers.edu"
    WebElement password = driver.findElement(By.id('password'))
    type password, '1234567'
    WebElement create = driver.findElement(By.id('create'))
    create.click()

    then:
    driver.title == 'Show Teacher'
    driver.findElement(By.className('message')).text ==~ /Teacher \d+ created/
  }

  def type(field, text) {
    field.clear()
    field.sendKeys text
  }

We can satisfy this specification by generating a Grails Teacher domain object with fields for the name, email and password, along with a Grails controller that uses dynamic scaffolding to generate the view and actions available, and by adding a link to the scaffolded Create Teacher page on our web/grails-app/views/index.gsp. Since the point of this tutorial is not to cover basic Grails development, we do not need to go into those specifics here, but sample code can be found on the project site or in any Grails tutorial for further reference.


Page Objects

Before we get too far with automating our acceptance criteria, we should begin to look towards a more abstract representation of the testable components of our application. From the second acceptance test above, we can already see patterns emerging. For example, when we navigate to a particular page, it would be convenient to identify that we are on the correct page, perhaps by its title. It would also be convenient to represent each view in the application as its own object, and each view could encapsulate its specific WebElements. Finally, there is a small set of actions available for any WebElement on a page, and it would be helpful to build these actions into a testing DSL. Fortunately, by combining the Page Object pattern and Groovy's dynamic language features, we can achieve all these goals with a minimal amount of code.

As we are satisfying the specifications for all the acceptance criteria, a base Page Object emerges.

specifications/src/test/groovy/timezra/course_registry/pages/CourseRegistryPage.groovy
package timezra.course_registry.pages

import static org.apache.commons.lang.StringUtils.splitByCharacterTypeCamelCase

import org.openqa.selenium.WebDriver
import org.openqa.selenium.WebElement
import org.openqa.selenium.support.FindBy
import org.openqa.selenium.support.PageFactory
import org.openqa.selenium.support.ui.Select

abstract class CourseRegistryPage {

  WebDriver driver

  @FindBy(className = "message")
  WebElement message

  static <T extends CourseRegistryPage> T goTo(String address, WebDriver driver, Class<T> page) {
    driver.get address
    PageFactory.initElements driver, page
  }

  CourseRegistryPage(WebDriver driver) {
    this.driver = driver
    String title = splitByCharacterTypeCamelCase(getClass().simpleName).join ' '
    if(!title.equals(driver.title)) {
      throw new IllegalStateException("Should be on page '${title}' but was on page '${driver.title}' instead")
    }
  }

  def methodMissing(String name, args) {
    def m
    if((m = name =~ /click_(\w+)/)) {
      def webElement = this."${m[0][1]}"
      webElement.click()
      PageFactory.initElements driver, args[0]
    }
    else if((m = name =~ /type_(\w+)/)) {
      def webElement = this."${m[0][1]}"
      webElement.clear()
      webElement.sendKeys args[0]
    }
    else if((m = name =~ /choose_(\w+)/)) {
      def webElement = this."${m[0][1]}"
      def select = new Select(webElement)
      select.selectByVisibleText args[0]
    }
    else {
      throw new MissingMethodException(name, getClass(), args)
    }
  }
}

NB: The constructor verifies that the page is correct by comparing the class name to the page title; in addition, the static goTo method demonstrates how to use the PageFactory for initializing a Page Object; the methodMissing declaration also showcases Groovy's ability to call dynamic methods based on the combination of the click, type and choose actions with individual WebElement names. Such phrases become first-class elements of the testing DSL.


We can see the use of WebElement injection and dynamic methods in action with a CreateUser page that serves as the base for the CreateTeacher and CreateStudent pages.


specifications/src/test/groovy/timezra/course_registry/pages/CreateUser.groovy

package timezra.course_registry.pages

import groovy.transform.InheritConstructors

import org.openqa.selenium.WebDriver
import org.openqa.selenium.WebElement

@InheritConstructors
abstract class CreateUser extends CourseRegistryPage {
  WebElement name
  WebElement email
  WebElement password
  WebElement create

  CreateUser(WebDriver driver) {
    super(driver)
  }

  protected <T> T register(name, email, password, Class<T> nextPage) {
    type_name name
    type_email email
    type_password password
    click_create nextPage
  }
}


Other Browsers

So far, we have done all our testing with a single WebDriver, i.e., the FirefoxDriver. For simple testing, a single driver works fine, but if we wish to ensure that our website works in multiple browsers, we will need to configure our tests to use multiple WebDrivers. Fortunately, since we are using Gradle to manage our dependencies, the InternetExplorerDriver and ChromeDriver should be available to us automatically, along with the AndroidDriver and IPhoneDriver for testing our application on mobile devices. In addition, an OperaDriver is also available.

NB: Some of these drivers require that additional platform-specific software be installed, so please read the project pages for documentation on additional requirements.


Configuring our specifications to use multiple WebDrivers should just be a matter of parameterizing each Spock feature.

NB: Unlike JUnit tests which are parameterized by fixture, Spock specifications are parameterized per feature.


We will modify our TeacherRegistrationSpec to use the Spock where: block for parameterization, we will configure multiple WebDrivers, and we will share the instance driver field among the parameterized features.

timezra/course_registry/TeacherRegistrationSpec.groovy
class TeacherRegistrationSpec extends Specification {

  @Shared
  WebDriver driver

  def setup() {
    driver.manage().timeouts().implicitlyWait 10, SECONDS
  }

  def cleanup() {
    driver.quit()
  }

  def "a user is greeted with an intro screen"() {
    when:
    ....

    then:
    ....

    where:
    browser << browsers()
  }

  def "a user can register as a teacher"() {
    when:
    ....

    then:
    ....

    where:
    browser << browsers()
  }
  
  ....

  protected def browsers() {
    System.setProperty("webdriver.chrome.driver", "/path/to/chromedriver")
    def drivers = [
      new HtmlUnitDriver(),
      new FirefoxDriver(),
      new ChromeDriver()
    ]
    new Browsers(spec: this, delegate: drivers.iterator())
  }

  private static final class Browsers {
    @Delegate Iterator<WebDriver> delegate
    TeacherRegistrationSpec spec

    @Override WebDriver next() {
      spec.driver = delegate.next()
    }
  }
}

Testing In The Cloud

Now that our specifications are running through multiple browsers on a single machine, we can move a step further towards testing multiple browser versions on multiple OSes with cloud-based services such as Sauce Labs. For such a powerful service, the configuration changes to our existing application are surprisingly simple. We will modify our TeacherRegistrationSpec to use a RemoteWebDriver for the specific browser and OS combination we would like to test along with our Sauce Labs username and API key as described in the Sauce OnDemand documentation. We will also need to add a Gradle plugin to start SauceConnect from the build, just as we start our web application before running acceptance tests. Finally, we should ensure that all references to localhost in our application are changed to the IP address of the machine where we will be running our tests; otherwise, we might see the SauceConnect proxy freeze (you might have a different experience, and this freezing might just be attributable to the Gremlins in my machine).


timezra/course_registry/TeacherRegistrationSpec.groovy
class TeacherRegistrationSpec extends Specification {
  ....
  def "a user is greeted with an intro screen"() {
    when:
    driver.get "http://<your.ip.address>:8080/course_registry"
    ....
  }

  def "a user can register as a teacher"() {
    when:
    driver.get "http://<your.ip.address>:8080/course_registry/"
    ....
  }
  ....
  protected def browsers() {
    def capabilities = DesiredCapabilities.internetExplorer()
    capabilities.setCapability("version", "7")
    capabilities.setCapability("platform", Platform.XP)
    capabilities.setCapability("name", "Testing Teacher Registration in Sauce")

    def sauceDriver = new RemoteWebDriver(
        new URL(
        "http://<username>:<apiKey>@ondemand.saucelabs.com:80/wd/hub"),
        capabilities)
    def drivers = [sauceDriver]
    new Browsers(spec: this, delegate: drivers.iterator())
  }
  ....
}

course_registry/specifications/build.gradle
import java.util.concurrent.Executors
import java.util.concurrent.ExecutorService
....
repositories {
  ....
  mavenRepo url: "https://repository-saucelabs.forge.cloudbees.com/release"
}

dependencies {
  ....
  runtime group: 'com.saucelabs', name: 'sauce-connect', version: '3.0.18'
}

apply plugin: SauceConnect

sauceConfig {
  username = '<username>'
  apiKey = '<apiKey>'
}

test.dependsOn ':web:webStart', 'sauceConnect'

gradle.taskGraph.afterTask { Task task, TaskState state ->
  if(':specifications:test'.equals(task.path)) {
    tasks.getByPath('sauceDisconnect').execute()
    project(':web').tasks.getByPath('webStop').execute()
  }
}

class SauceConnect implements Plugin<Project> {
  
  ExecutorService executor = Executors.newFixedThreadPool(2)
  
  def void apply(Project project) {
    project.extensions.sauceConfig = new SauceConnectExtension()
    
    project.task('sauceConnect') << {
      
      def output = new PipedOutputStream()
      def input = new PipedInputStream(output)
      def reader = new BufferedReader(new InputStreamReader(input))
      
      executor.execute {
        println "Connecting to Sauce Labs as ${project.sauceConfig.username} with key ${project.sauceConfig.apiKey}...."
        try {
          project.javaexec {
            main = 'com.saucelabs.sauceconnect.SauceConnect'
            classpath = project.sourceSets.main.runtimeClasspath
            args = [project.sauceConfig.username, project.sauceConfig.apiKey]
            standardOutput = output
          } 
        } catch(Exception ignored) {
          // Executor has been shutdown
        }
      }
      boolean okToStart = false
      executor.execute {
        def nextLine
        try {
          while((nextLine = reader.readLine()) != null) {
            println nextLine
            if(!okToStart) {
              if(nextLine =~ /Please wait for "You may start your tests" to start your tests/) {
                continue
              }
              else if(nextLine =~ /You may start your tests/) {
                okToStart = true
              }
            }
          }
        } catch(Exception ignored) {
          // Executor has been shutdown
        }
      }
      while(!okToStart) {
        Thread.sleep 250
      }
    }
    
    project.task('sauceDisconnect') << {
      println "Disconnecting from Sauce Labs...."
      executor.shutdownNow()
    }
  }
}

class SauceConnectExtension {
  String username
  String apiKey
}

Conclusion

This post took us through the definition of user stories for a small website, the setup of a Gradle project for automating the website build and the run of its corresponding acceptance tests, the creation of our first WebDriver-based Spock specification for our acceptance criteria and the fulfillment of that spec with a Grails 2.0 application, the use of Selenium PageObjects to create abstract representations of our web pages before those web pages are even written, all the way to the creation of a Gradle plugin for running our acceptance tests on various browser/OS combinations in Sauce Labs. Each of those items in itself is worthy of a tutorial series. The combination of all these elements demonstrates with a remarkably small amount of configuration and code the type of robust and scalable ATDD infrastructure that is possible and should be at the core of any enterprise web application.


42 comments:

Keerthi said...

Great and really helpful article! Adding to the conversation, providing more information, or expressing a new point of view...Nice information and updates. Really i like it and everyday am visiting your site..

Selenium Training in Chennai

svrtechnologies said...

Hey Tim Myer You Will defenitely reach Our Goal by developing a web site specification-first using Spock and Selenium Web Driver

Sakthi Murugan said...

Wonderful way of writing with suitable examples. Really an interesting article.
Selenium Training in Chennai
selenium testing training in chennai
iOS Training in Chennai
Digital Marketing Course in adyar
Digital Marketing Course in tambaram

Prityyou said...

Outstanding blog thanks for sharing such wonderful blog with us ,after long time came across such knowlegeble blog. keep sharing such informative blog with us.
Airport Ground Staff Training Courses in Chennai | Airport Ground Staff Training in Chennai | Ground Staff Training in Chennai

Aruna Ram said...

Your blog is very attractive. I much thanks for your great article. I want more updating keep it up....
SEO Training in Chennai Velachery
SEO Training in Tnagar
SEO Course in Nungambakkam
SEO Training in Tambaram
SEO Course in Kandanchavadi
SEO Training in Sholinganallur

mercyroy said...


Amazing information,thank you for your ideas.after along time i have studied
an interesting information's.we need more updates in your blog.
AWS Training in Amjikarai
AWS Training in Thirumangalam
AWS Training Institutes in Bangalore

Aruna Ram said...

Really useful information about this,very helful for me. Keep it up.
Data Science Certification in Bangalore
Best Data Science Courses in Bangalore
Data Science Training in Adyar
Data Science Training in Ambattur
Data Science Training in Tnagar
Data Science Course in Vadapalani

Anonymous said...

Your blog is very informative. It is easily understandable and explained thoroughly.

Excel Advanced course
Advanced Excel Course in Chennai
Excel Advanced Training
Excel Coaching Classes in Chennai
Advanced Excel Training
Advanced Excel Course in Tambaram

mercyroy said...

Informative post,It is useful for me to clear my doubts.I hope others also like the information you gave in your blog.
best android institute in bangalore
Android Training in chennai
Android Training in Vadapalani
Android Training in Padur

Aruna Ram said...

I appreciate you sharing this post. Thanks for your efforts in sharing this information in detail. kindly keep continuing the good job.
Automation Courses in Bangalore
RPA Courses in Bangalore
RPA Training in Bangalore
Robotics Courses in Bangalore
Robotics Classes in Bangalore
Robotics Training in Bangalore

Anjali Siva said...

Learned a lot from your blog. Good creation and hats off to the creativity of your mind. Share more like this.
Robotic Process Automation Certification
RPA course
Robotic Process Automation Courses
learn Robotic Process Automation
RPA Training Course
RPA Training Institute in Chennai

rupa said...

In the beginning, I would like to thank you much about this great post. Its very useful and helpful for anyone looking for tips to help him learn and master in Angularjs. I like your writing style and I hope you will keep doing this good working.
Angularjs Training in Bangalore
Angularjs Training Institute In Bangalore
Android App Development Training in Bangalore
Android Training Center in Bangalore

VenuBharath2010@gmail.com said...

Awesome Post. It shows your in-depth knowledge on the content. Thanks for sharing.

Xamarin Training in Chennai
Xamarin Course in Chennai
Xamarin Training
Xamarin Course
Xamarin Training Course
Xamarin Classes
Best Xamarin Course
Xamarin Training Institute in Chennai
Xamarin Training Institutes in Chennai

VenuBharath2010@gmail.com said...


Awesome Post. It shows your in-depth knowledge on the content. Thanks for Sharing.
Informatica Training in Chennai
Informatica Training center Chennai
Informatica Training Institute in Chennai
Best Informatica Training in Chennai
Informatica Course in Chennai
IELTS coaching in Chennai
IELTS Training in Chennai
IELTS coaching centre in Chennai

LindaJasmine said...


Great Article. The way you express in extra-ordinary. The information provided is very useful. Thanks for Sharing. Waiting for your next post.
SAS Training in Chennai
SAS Course in Chennai
SAS Training Institutes in Chennai
SAS Institute in Chennai
Clinical SAS Training in Chennai
SAS Analytics Training in Chennai
Photoshop Classes in Chennai
Photoshop Course in Chennai
Photoshop Training in Chennai

Vicky Ram said...

Excellent guys...Great work !!!!

Article submission sites
Education

jefrin said...

Great article keep posting
https://www.slajobs.com/advanced-excel-vba-training-in-chennai/

rohini said...

This blog is the general information for the feature. You got a good work for these blog.We have a developing our creative content of this mind.Thank you for this blog. This for very interesting and useful.
apple service center chennai | Mac service center in chennai | ipod service center in chennai | Apple laptop service center in chennai

Mr. Yansh said...

This information really amazing thanks for share this article thank you..

good morning shayari

htop said...

thanks for sharing this information
aws training center in chennai
aws training in chennai
aws training institute in chennai
best angularjs training in chennai
angular js training in sholinganallur
angularjs training in chennai
azure training in chennai

Fasts News said...

Amazing WEbsite

토토사이트

Sri prathana said...

Thanks for sharing this article. Really helpful for me.

winter internship for ece students
electrical companies in hyderabad for internship
internship in indore for computer science students
free internship in chennai chennai, tamil nadu
free internship in chennai chennai, tamil nadu
internship for electrical engineering students in bangalore
internship in automobile industry
internship in chennai for mca
free ethical hacking course in chennai
paid internship in pune for computer engineering students

hammad said...

ddd

hammad said...

So if you are one of the person who like to carry lots of gears with them and looking for some additional storage. Then we are listing some of the best Sit on Top kayaks for you.

Sit on Kayaks

hari said...

very goodinplant training in chennai
inplant training in chennai for it
suden web hosting
tunisia hosting
uruguay web hosting
Bermuda web hosting
Botswana hosting
armenia web hosting
lebanon web hosting

shreekavi said...


Get inspired by your blog. Keep doing like this....
Selenium Training in Chennai
Selenium Training in Bangalore
Selenium Training in Coimbatore
Best Selenium Training in Bangalore
Selenium Training Institute in Bangalore
Selenium Classes in Bangalore
selenium training in marathahalli
Selenium training in Btm
Ielts coaching in bangalore
German classes in bangalore

karthickannan said...

very good blogs...
coronavirus update
inplant training in chennai
inplant training
inplant training in chennai for cse
inplant training in chennai for ece
inplant training in chennai for eee
inplant training in chennai for mechanical
internship in chennai
online internship

subha said...

Well, congrats for getting launched first of all. IT is on boom these days and offering services like these is going to help you run your business perfectly. Thanks good jobs guys
Ai & Artificial Intelligence Course in Chennai
PHP Training in Chennai
Ethical Hacking Course in Chennai Blue Prism Training in Chennai
UiPath Training in Chennai

Aishu said...

I appreciate that you produced this wonderful article to help us get more knowledge about this topic.
I know, it is not an easy task to write such a big article in one day, I've tried that and I've failed. But, here you are, trying the big task and finishing it off and getting good comments.
IELTS Coaching in chennai

German Classes in Chennai

GRE Coaching Classes in Chennai

TOEFL Coaching in Chennai

spoken english classes in chennai | Communication training

Free CSEET Online Classes said...

You guys will be greatful to know that our institution is conducting online CS executive classes and a free CSEET classes only for you guys. If anyone is interested then feel free to contact us or visit our website for more details https://uniqueacademyforcommerce.com/

HOME1 said...

Search Chennai real estate, Chennai property, Chennai Home For Sale, Chennai Land for Sale, property in Chennai, real estate in Chennai.
chennai
best-villa
visit here

Elena James said...

How Do You Lose Currency Trading In IC Market

hussain d said...

You've made some good points there. I looked on the internet for additional
information about the issue and found most people will go along with your
views on this website.
UI Development Training in Bangalore
Data Science with Python Training in Bangalore
Python Training in Bangalore
AWS Training in Bangalore
Machine Learning with Python Training in Bangalore
Devops Training in Bangalore

Mrbk30 said...

Very Informative blog thank you for sharing. Keep sharing.

Best software training institute in Chennai. Make your career development the best by learning software courses.

devops training in chennai with placement
ios training in chennai
cloud computing training in chennai

Angel calista said...

Utilize the Google Play Promo-code GET3 to Acquire outfit advantages up to $1210 from PUBG cellular sport. Go here to find out more about any of it. Utilize GP300 to Find Cash Back around $300 or utilize GOOGLEPLAY for around $75 Cash-back on Google Play Re-charge (Conditions Applied).amazon com code
amazon code
Play.google.com/redeem
amazon com code
Prime video mytv

Links For You said...

I have to favored this site it appears to be out and out obliging. Top notch information, rebuild everyone mind just reference appoint help to it. YTD Pro Crack

Rajput said...

Reloader Activator Free Download
Fine page, in which did u come happening a distant memory the assessment concerning this posting?i have right of access the majority of the articles with respect to your web website now, and I as a matter of fact in addition to your style. much thanks to you a million and absorb save happening the vivacious deed. Reloader Ultima Versione

VIPTHANOSS said...

fun rummy

Anonymous said...

Very Nice Information
how to find cheap musical tickets london

Bavya Izeon said...

"We appreciate your interest in engaging topics and illustrative examples Are you looking for reliable and efficient Selenium Testing Training in Chennai
? Look no further! At Izeon Innovative Private Limited, we specialize in providing Top-Notch Selenium Testing Solutions to businesses of all sizes. With our expertise and advanced testing techniques, we ensure that your software performs flawlessly across different platforms and browsers"

Antalya Dijital said...
This comment has been removed by the author.
Free Video Editor said...

very beautifully writtenfreevideoeditor.site